diff --git a/.gitattributes b/.gitattributes index 5254fc8aa24..0123be983d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,5 @@ # Auto-detect text files, ensure they use LF. * text=auto eol=lf working-tree-encoding=UTF-8 - # Bash scripts *.sh text eol=lf -*.cmd text eol=crlf \ No newline at end of file +*.cmd text eol=crlf diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..352d0b22f7a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,67 @@ +# GitHub Copilot Instructions + +This repository contains both Python and C# code. +All python code resides under the `python/` directory. +All C# code resides under the `dotnet/` directory. + +The purpose of the code is to provide a framework for building AI agents. + +When contributing to this repository, please follow these guidelines: + +## C# Code Guidelines + +Here are some general guidelines that apply to all code. + +- The top of all *.cs files should have a copyright notice: `// Copyright (c) Microsoft. All rights reserved.` +- All public methods and classes should have XML documentation comments. + +### C# Sample Code Guidelines + +Sample code is located in the `dotnet/samples` directory. + +When adding a new sample, follow these steps: + +- The sample should be a standalone .net project in one of the subdirectories of the samples directory. +- The directory name should be the same as the project name. +- The directory should contain a README.md file that explains what the sample does and how to run it. +- The README.md file should follow the same format as other samples. +- The csproj file should match the directory name. +- The csproj file should be configured in the same way as other samples. +- The project should preferably contain a single Program.cs file that contains all the sample code. +- The sample should be added to the solution file in the samples directory. +- The sample should be tested to ensure it works as expected. +- A reference to the new samples should be added to the README.md file in the parent directory of the new sample. + +The sample code should follow these guidelines: + +- Configuration settings should be read from environment variables, e.g. `var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");`. +- Environment variables should use upper snake_case naming convention. +- Secrets should not be hardcoded in the code or committed to the repository. +- The code should be well-documented with comments explaining the purpose of each step. +- The code should be simple and to the point, avoiding unnecessary complexity. +- Prefer inline literals over constants for values that are not reused. For example, use `new ChatClientAgent(chatClient, instructions: "You are a helpful assistant.")` instead of defining a constant for "instructions". +- Ensure that all private classes are sealed +- Use the Async suffix on the name of all async methods that return a Task or ValueTask. +- Prefer defining variables using types rather than var, to help users understand the types involved. +- Follow the patterns in the samples in the same directories where new samples are being added. +- The structure of the sample should be as follows: + - The top of the Program.cs should have a copyright notice: `// Copyright (c) Microsoft. All rights reserved.` + - Then add a comment describing what the sample is demonstrating. + - Then add the necessary using statements. + - Then add the main code logic. + - Finally, add any helper methods or classes at the bottom of the file. + +### C# Unit Test Guidelines + +Unit tests are located in the `dotnet/tests` directory in projects with a `.UnitTests.csproj` suffix. + +Unit tests should follow these guidelines: + +- Use `this.` for accessing class members +- Add Arrange, Act and Assert comments for each test +- Ensure that all private classes, that are not subclassed, are sealed +- Use the Async suffix on the name of all async methods +- Use the Moq library for mocking objects where possible +- Validate that each test actually tests the target behavior, e.g. we should not have tests that creates a mock, calls the mock and then verifies that the mock was called, without the target code being involved. We also shouldn't have tests that test language features, e.g. something that the compiler would catch anyway. +- Avoid adding excessive comments to tests. Instead favour clear easy to understand code. +- Follow the patterns in the unit tests in the same project or classes to which new tests are being added diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 7060d252ae7..69dc92a9cbd 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -160,6 +160,7 @@ jobs: AzureAI__DeploymentName: ${{ vars.AZUREAI__DEPLOYMENTNAME }} AzureAI__BingConnectionId: ${{ vars.AZUREAI__BINGCONECTIONID }} FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MEDIA_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MEDIA_DEPLOYMENT_NAME }} FOUNDRY_MODEL_DEPLOYMENT_NAME: ${{ vars.FOUNDRY_MODEL_DEPLOYMENT_NAME }} FOUNDRY_CONNECTION_GROUNDING_TOOL: ${{ vars.FOUNDRY_CONNECTION_GROUNDING_TOOL }} diff --git a/.github/workflows/python-test-coverage-report.yml b/.github/workflows/python-test-coverage-report.yml index 811e65098c7..58ae812fefe 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.56 + uses: MishaKav/pytest-coverage-comment@v1.1.57 with: github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} issue-number: ${{ env.PR_NUMBER }} diff --git a/README.md b/README.md index dfdf896922a..30d9ab2bdd6 100644 --- a/README.md +++ b/README.md @@ -119,22 +119,35 @@ if __name__ == "__main__": ### Basic Agent - .NET +Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework + +```c# +// dotnet add package Microsoft.Agents.AI.OpenAI --prerelease +using System; +using OpenAI; + +// Replace the with your OpenAI API key. +var agent = new OpenAIClient("") + .GetOpenAIResponseClient("gpt-4o-mini") + .CreateAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); + +Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); +``` + +Create a simple Agent, using Azure OpenAI Responses with token based auth, that writes a haiku about the Microsoft Agent Framework + ```c# // dotnet add package Microsoft.Agents.AI.OpenAI --prerelease -// dotnet add package Azure.AI.OpenAI // dotnet add package Azure.Identity // Use `az login` to authenticate with Azure CLI using System; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; using OpenAI; -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!; -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME")!; - -var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName) +// Replace and gpt-4o-mini with your Azure OpenAI resource name and deployment name. +var agent = new OpenAIClient( + new BearerTokenPolicy(new AzureCliCredential(), "https://ai.azure.com/.default"), + new OpenAIClientOptions() { Endpoint = new Uri("https://.openai.azure.com/openai/v1") }) + .GetOpenAIResponseClient("gpt-4o-mini") .CreateAIAgent(name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully."); Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework.")); diff --git a/SUPPORT.md b/SUPPORT.md index bb2a4948dc9..a95ac5c597b 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -8,6 +8,10 @@ feature request as a new Issue. For help and questions about using this project, please create a GitHub issue. +AI Support team will support Microsoft Agent Framework issues for customers under a **Unified support agreement when the issue arises from usage of Azure AI services** (Foundry Models, Foundry Agents etc.) in conjunction with the SDK. Conversely, if customer has any other / non unified support agreement and/or Agent Framework SDK is used in a way **not involving an Azure service**, it is treated as a purely open-source tool – Microsoft’s support organization will not handle it, and users should use GitHub or forums for assistance + +For Copilot Studio SDK implementation issues, customers should use GitHub Issues for assistance, as outlined above. Conversely, for prerequisites managed within the Copilot Studio portal, customers can rely on the standard Microsoft Copilot Studio support channels. + ## Microsoft Support Policy Support for this **PROJECT or PRODUCT** is limited to the resources listed above. diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index bf589d7fe01..b60878adffe 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -499,7 +499,7 @@ We need to decide what AIContent types, each agent response type will be mapped | AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.structured_output) | | LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response | | Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/examples/getting-started/structured-output) at agent construction time | -| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2aproject.github.io/A2A/v0.2.5/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | +| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | | Protocol Activity | Supports returning [Complex types](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md#complex-types) but no support for requesting a type | ### Response Reason Support @@ -511,5 +511,5 @@ We need to decide what AIContent types, each agent response type will be mapped | AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/api-reference/types/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) class with options that are tied closely to LLM operations. | | LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) | -| A2A | No equivalent present, response only contains a [message](https://a2aproject.github.io/A2A/v0.2.5/specification/#64-message-object) or [task](https://a2aproject.github.io/A2A/v0.2.5/specification/#61-task-object). | +| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). | | Protocol Activity | [No equivalent present.](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md) | diff --git a/dotnet/.vscode/settings.json b/dotnet/.vscode/settings.json index 2a6a38bc59b..4fa848ae289 100644 --- a/dotnet/.vscode/settings.json +++ b/dotnet/.vscode/settings.json @@ -1,4 +1,5 @@ { "dotnet.defaultSolution": "agent-framework-dotnet.slnx", - "git.openRepositoryInParentFolders": "always" + "git.openRepositoryInParentFolders": "always", + "chat.agent.enabled": true } diff --git a/dotnet/Directory.Build.props b/dotnet/Directory.Build.props index 98d593acafb..6b61196bbd9 100644 --- a/dotnet/Directory.Build.props +++ b/dotnet/Directory.Build.props @@ -10,7 +10,8 @@ enable $(NoWarn);NU5128 true - net9.0 + net9.0;net8.0 + net9.0 net9.0;net8.0;netstandard2.0;net472 net9.0;net472 true diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index e17c0ec0813..636a086ee37 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -10,88 +10,78 @@ 9.5.1 - - + + - - - - - - - - - - - - - - - - - - + + + + + - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - - - - - + + + + + - + - - + + + @@ -99,12 +89,14 @@ + + - + - - + + @@ -113,7 +105,6 @@ - @@ -130,7 +121,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 81a2da4a490..9d3b86535c6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -21,6 +21,10 @@ + + + + @@ -113,6 +117,7 @@ + @@ -124,59 +129,11 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 0cc8803cbde..4e0e7246c4a 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -2,8 +2,9 @@ 1.0.0 - $(VersionPrefix)-$(VersionSuffix).251007.1 - $(VersionPrefix)-preview.251007.1 + $(VersionPrefix)-$(VersionSuffix).251016.1 + $(VersionPrefix)-preview.251016.1 + 1.0.0-preview.251016.1 Debug;Release;Publish true diff --git a/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj b/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj index 3ba914b69c2..77a05882319 100644 --- a/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj +++ b/dotnet/samples/A2AClientServer/A2AClient/A2AClient.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj index 06879c9330a..bcff11f17cf 100644 --- a/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/A2AClientServer/A2AServer/A2AServer.csproj @@ -12,8 +12,8 @@ - - + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj index 128ef4651ae..802c864c1fe 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/AgentWebChat.AgentHost.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 9998953bba3..58fe403c8df 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -66,6 +66,25 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te #pragma warning restore VSTHRD002 }); +// Workflow consisting of multiple specialized agents +var chemistryAgent = builder.AddAIAgent("chemist", + instructions: "You are a chemistry expert. Answer thinking from the chemistry perspective", + description: "An agent that helps with chemistry.", + chatClientServiceKey: "chat-model"); + +var mathsAgent = builder.AddAIAgent("mathematician", + instructions: "You are a mathematics expert. Answer thinking from the maths perspective", + description: "An agent that helps with mathematics.", + chatClientServiceKey: "chat-model"); + +var literatureAgent = builder.AddAIAgent("literator", + instructions: "You are a literature expert. Answer thinking from the literature perspective", + description: "An agent that helps with literature.", + chatClientServiceKey: "chat-model"); + +builder.AddSequentialWorkflow("science-sequential-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); +builder.AddConcurrentWorkflow("science-concurrent-workflow", [chemistryAgent, mathsAgent, literatureAgent]).AddAsAIAgent(); + var app = builder.Build(); app.MapOpenApi(); @@ -89,6 +108,13 @@ Once the user has deduced what type (knight or knave) both Alice and Bob are, te app.MapOpenAIResponses("pirate"); app.MapOpenAIResponses("knights-and-knaves"); +app.MapOpenAIChatCompletions("pirate"); +app.MapOpenAIChatCompletions("knights-and-knaves"); + +// workflow-agents +app.MapOpenAIResponses("science-sequential-workflow"); +app.MapOpenAIResponses("science-concurrent-workflow"); + // Map the agents HTTP endpoints app.MapAgentDiscovery("/agents"); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index a785990f1a4..3a2d3560a91 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -10,7 +10,7 @@ namespace AgentWebChat.Web; -internal sealed class A2AAgentClient : IAgentClient +internal sealed class A2AAgentClient : AgentClientBase { private readonly ILogger _logger; private readonly Uri _uri; @@ -25,7 +25,7 @@ public A2AAgentClient(ILogger logger, Uri baseUri) this._uri = baseUri; } - public async IAsyncEnumerable RunStreamingAsync( + public async override IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -126,7 +126,7 @@ public async IAsyncEnumerable RunStreamingAsync( } } - public async Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + public async override Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) { this._logger.LogInformation("Retrieving agent card for {Agent}", agentName); diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj index cba03eb0c9e..72541f046f1 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/AgentWebChat.Web.csproj @@ -4,6 +4,7 @@ net9.0 enable enable + $(NoWarn);CA1812 @@ -16,8 +17,8 @@ - - + + diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor index 2347f31c648..5642aa0ff3b 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor @@ -5,6 +5,7 @@ @inject ILogger Logger @inject A2AAgentClient A2AActorClient @inject OpenAIResponsesAgentClient OpenAIResponsesAgentClient +@inject OpenAIChatCompletionsAgentClient OpenAIChatCompletionsAgentClient @rendermode InteractiveServer @using System.Text @using System.Text.Json @@ -52,14 +53,18 @@
@switch (selectedProtocol) { case Protocol.OpenAIResponses: ֎ OpenAI Responses + break; + case Protocol.OpenAIChatCompletions: + ֎ OpenAI ChatCompletions break; case Protocol.A2A: default: @@ -903,7 +908,8 @@ private enum Protocol { A2A, // Agent-to-Agent protocol - OpenAIResponses + OpenAIResponses, + OpenAIChatCompletions } private sealed class Conversation @@ -1080,11 +1086,11 @@ try { - // Select the appropriate client based on protocol - IAgentClient agentClient = selectedProtocol switch + AgentClientBase agentClient = selectedProtocol switch { Protocol.OpenAIResponses => OpenAIResponsesAgentClient, + Protocol.OpenAIChatCompletions => OpenAIChatCompletionsAgentClient, Protocol.A2A or _ => A2AActorClient }; diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs index 13f1824c64a..2d08ef5e45f 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/IAgentClient.cs @@ -9,7 +9,7 @@ namespace AgentWebChat.Web; /// /// Interface for clients that can interact with agents and provide streaming responses. /// -public interface IAgentClient +internal abstract class AgentClientBase { /// /// Runs an agent with the specified messages and returns a streaming response. @@ -19,7 +19,7 @@ public interface IAgentClient /// Optional thread identifier for conversation continuity. /// Cancellation token. /// An asynchronous enumerable of agent response updates. - IAsyncEnumerable RunStreamingAsync( + public abstract IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -31,7 +31,8 @@ IAsyncEnumerable RunStreamingAsync( /// The name of the agent. /// Cancellation token. /// The agent card if supported, null otherwise. - Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default); + public virtual Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) + => Task.FromResult(null); } /// diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs new file mode 100644 index 00000000000..ae71a876786 --- /dev/null +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIChatCompletionsAgentClient.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Runtime.CompilerServices; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace AgentWebChat.Web; + +/// +/// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI ChatCompletions protocol. +/// +internal sealed class OpenAIChatCompletionsAgentClient(HttpClient httpClient) : AgentClientBase +{ + public async override IAsyncEnumerable RunStreamingAsync( + string agentName, + IList messages, + string? threadId = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + OpenAIClientOptions options = new() + { + Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(httpClient) + }; + + var openAiClient = new ChatClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); + await foreach (var update in openAiClient.GetStreamingResponseAsync(messages, cancellationToken: cancellationToken)) + { + yield return new AgentRunResponseUpdate(update); + } + } +} diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs index 8ec81c0e48f..524538bbf9d 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs @@ -1,8 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System.ClientModel; +using System.ClientModel.Primitives; using System.Runtime.CompilerServices; -using A2A; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; @@ -13,16 +13,9 @@ namespace AgentWebChat.Web; /// /// Is a simple frontend client which exercises the ability of exposed agent to communicate via OpenAI Responses protocol. /// -internal sealed class OpenAIResponsesAgentClient : IAgentClient +internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentClientBase { - private readonly Uri _baseUri; - - public OpenAIResponsesAgentClient(string baseUri) - { - this._baseUri = new Uri(baseUri.TrimEnd('/')); - } - - public async IAsyncEnumerable RunStreamingAsync( + public async override IAsyncEnumerable RunStreamingAsync( string agentName, IList messages, string? threadId = null, @@ -30,7 +23,8 @@ public async IAsyncEnumerable RunStreamingAsync( { OpenAIClientOptions options = new() { - Endpoint = new Uri(this._baseUri, $"/{agentName}/v1/") + Endpoint = new Uri(httpClient.BaseAddress!, $"/{agentName}/v1/"), + Transport = new HttpClientPipelineTransport(httpClient) }; var openAiClient = new OpenAIResponseClient(model: "myModel!", credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); @@ -44,7 +38,4 @@ public async IAsyncEnumerable RunStreamingAsync( yield return new AgentRunResponseUpdate(update); } } - - public Task GetAgentCardAsync(string agentName, CancellationToken cancellationToken = default) - => Task.FromResult(null!); } diff --git a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs index 2cee27e2697..665aaa1bba5 100644 --- a/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs +++ b/dotnet/samples/AgentWebChat/AgentWebChat.Web/Program.cs @@ -23,7 +23,9 @@ builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); builder.Services.AddSingleton(sp => new A2AAgentClient(sp.GetRequiredService>(), a2aAddress)); -builder.Services.AddSingleton(sp => new OpenAIResponsesAgentClient("http://localhost:5390")); + +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); +builder.Services.AddHttpClient(client => client.BaseAddress = baseAddress); var app = builder.Build(); diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj new file mode 100644 index 00000000000..2b89b20fbf1 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs new file mode 100644 index 00000000000..f0b138c2c77 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to represent an A2A agent as a set of function tools, where each function tool +// corresponds to a skill of the A2A agent, and register these function tools with another AI agent so +// it can leverage the A2A agent's skills. + +using System.Text.RegularExpressions; +using A2A; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent a2aAgent = await agentCard.GetAIAgentAsync(); + +// Create the main agent, and provide the a2a agent skills as a function tools. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are a helpful assistant that helps people with travel planning.", + tools: [.. CreateFunctionTools(a2aAgent, agentCard)] + ); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent.RunAsync("Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls")); + +static IEnumerable CreateFunctionTools(AIAgent a2aAgent, AgentCard agentCard) +{ + foreach (var skill in agentCard.Skills) + { + // A2A agent skills don't have schemas describing the expected shape of their inputs and outputs. + // Schemas can be beneficial for AI models to better understand the skill's contract, generate + // the skill's input accordingly and to know what to expect in the skill's output. + // However, the A2A specification defines properties such as name, description, tags, examples, + // inputModes, and outputModes to provide context about the skill's purpose, capabilities, usage, + // and supported MIME types. These properties are added to the function tool description to help + // the model determine the appropriate shape of the skill's input and output. + AIFunctionFactoryOptions options = new() + { + Name = FunctionNameSanitizer.Sanitize(skill.Name), + Description = $$""" + { + "description": "{{skill.Description}}", + "tags": "[{{string.Join(", ", skill.Tags ?? [])}}]", + "examples": "[{{string.Join(", ", skill.Examples ?? [])}}]", + "inputModes": "[{{string.Join(", ", skill.InputModes ?? [])}}]", + "outputModes": "[{{string.Join(", ", skill.OutputModes ?? [])}}]" + } + """, + }; + + yield return AIFunctionFactory.Create(RunAgentAsync, options); + } + + async Task RunAgentAsync(string input, CancellationToken cancellationToken) + { + var response = await a2aAgent.RunAsync(input, cancellationToken: cancellationToken).ConfigureAwait(false); + + return response.Text; + } +} + +internal static partial class FunctionNameSanitizer +{ + public static string Sanitize(string name) + { + return InvalidNameCharsRegex().Replace(name, "_"); + } + + [GeneratedRegex("[^0-9A-Za-z]+")] + private static partial Regex InvalidNameCharsRegex(); +} diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md new file mode 100644 index 00000000000..6cbd56dca4c --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md @@ -0,0 +1,22 @@ +# A2A Agent as Function Tools + +This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, +and register these function tools with another AI agent so it can leverage the A2A agent's skills. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Access to the A2A agent host service + +**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be +spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md + +Set the following environment variables: + +```powershell +$env:A2A_AGENT_HOST="https://your-a2a-agent-host" # Replace with your A2A agent host endpoint +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/A2A/README.md b/dotnet/samples/GettingStarted/A2A/README.md new file mode 100644 index 00000000000..3ddac959967 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/README.md @@ -0,0 +1,50 @@ +# Agent-to-Agent (A2A) Samples + +These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework. + +For other samples that demonstrate how to use AIAgent instances, +see the [Getting Started With Agents](../Agents/README.md) samples. + +## Prerequisites + +See the README.md for each sample for the prerequisites for that sample. + +## Samples + +|Sample|Description| +|---|---| +|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| + +## Running the samples from the console + +To run the samples, navigate to the desired sample directory, e.g. + +```powershell +cd A2AAgent_AsFunctionTools +``` + +Set the required environment variables as documented in the sample readme. +If the variables are not set, you will be prompted for the values when running the samples. +Execute the following command to build the sample: + +```powershell +dotnet build +``` + +Execute the following command to run the sample: + +```powershell +dotnet run --no-build +``` + +Or just build and run in one step: + +```powershell +dotnet run +``` + +## Running the samples from Visual Studio + +Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. + +You will be prompted for any required environment variables if they are not already set. diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj index 4d78bf70c15..e01a9f74587 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_A2A/Agent_With_A2A.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs index a79b8058b24..264a9e45e88 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureFoundryModel/Program.cs @@ -14,9 +14,6 @@ var apiKey = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_APIKEY"); var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_MODEL_DEPLOYMENT") ?? "Phi-4-mini-instruct"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Since we are using the OpenAI Client SDK, we need to override the default endpoint to point to Azure Foundry. var clientOptions = new OpenAIClientOptions() { Endpoint = new Uri(endpoint) }; @@ -26,8 +23,8 @@ : new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions); AIAgent agent = client - .GetChatClient(model) - .CreateAIAgent(JokerInstructions, JokerName); + .GetChatClient(model) + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs index 29a8077aebe..bd31350258c 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIChatCompletion/Program.cs @@ -10,14 +10,11 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs index 6b6f7582d5a..6d162ebfd69 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs @@ -10,14 +10,11 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) .GetOpenAIResponseClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs index b8db89d2ef1..d6c306bfd17 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_ONNX/Program.cs @@ -10,12 +10,9 @@ // E.g. C:\repos\Phi-4-mini-instruct-onnx\cpu_and_mobile\cpu-int4-rtn-block-32-acc-level-4 var modelPath = Environment.GetEnvironmentVariable("ONNX_MODEL_PATH") ?? throw new InvalidOperationException("ONNX_MODEL_PATH is not set."); -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Get a chat client for ONNX and use it to construct an AIAgent. using OnnxRuntimeGenAIChatClient chatClient = new(modelPath); -AIAgent agent = chatClient.CreateAIAgent(JokerInstructions, JokerName); +AIAgent agent = chatClient.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs index 8ba07cd6343..8cacfef3eff 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_Ollama/Program.cs @@ -9,12 +9,9 @@ var endpoint = Environment.GetEnvironmentVariable("OLLAMA_ENDPOINT") ?? throw new InvalidOperationException("OLLAMA_ENDPOINT is not set."); var modelName = Environment.GetEnvironmentVariable("OLLAMA_MODEL_NAME") ?? throw new InvalidOperationException("OLLAMA_MODEL_NAME is not set."); -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Get a chat client for Ollama and use it to construct an AIAgent. AIAgent agent = new OllamaApiClient(new Uri(endpoint), modelName) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs index 631a7f29ae8..9b03c989e1d 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIChatCompletion/Program.cs @@ -8,13 +8,10 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_APIKEY") ?? throw new InvalidOperationException("OPENAI_APIKEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new OpenAIClient( apiKey) .GetChatClient(model) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Program.cs b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Program.cs index bd1cab2b6fc..1abefa0fcaf 100644 --- a/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Program.cs +++ b/dotnet/samples/GettingStarted/AgentProviders/Agent_With_OpenAIResponses/Program.cs @@ -8,13 +8,10 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_APIKEY") ?? throw new InvalidOperationException("OPENAI_APIKEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new OpenAIClient( apiKey) .GetOpenAIResponseClient(model) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs index faa32d80a0e..ccd42a20072 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Program.cs @@ -10,12 +10,9 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(model) - .CreateAIAgent(JokerInstructions, JokerName); + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); UserChatMessage chatMessage = new("Tell me a joke about a pirate."); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs index d5142381c97..c67756299ce 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step01_Running/Program.cs @@ -10,14 +10,11 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs index 17cab99bd4d..626a3e98c4a 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step02_MultiturnConversation/Program.cs @@ -10,14 +10,11 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent with a multi-turn conversation, where the context is preserved in the thread object. AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs index b2287fda4bc..48a6378e1f2 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step03_UsingFunctionTools/Program.cs @@ -21,8 +21,8 @@ static string GetWeather([Description("The location to get the weather for.")] s AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); // Non-streaming agent interaction with function tools. Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?")); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs index 31a545dbd62..41ea8a5c920 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -25,8 +25,8 @@ static string GetWeather([Description("The location to get the weather for.")] s AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are a helpful assistant", tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]); // Call the agent and check if there are any user input requests to handle. AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs index 87093525e7f..1ffe3c99935 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step06_PersistedConversations/Program.cs @@ -11,15 +11,12 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Create the agent AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Start a new thread for the agent conversation. AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs index 4daf74f569a..89867349721 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step07_3rdPartyThreadStorage/Program.cs @@ -17,9 +17,6 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Create a vector store to store the chat messages in. // Replace this with a vector store implementation of your choice if you want to persist the chat history to disk. VectorStore vectorStore = new InMemoryVectorStore(); @@ -28,19 +25,19 @@ AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(new ChatClientAgentOptions - { - Name = JokerName, - Instructions = JokerInstructions, - ChatMessageStoreFactory = ctx => - { - // Create a new chat message store for this agent that stores the messages in a vector store. - // Each thread must get its own copy of the VectorChatMessageStore, since the store - // also contains the id that the thread is stored under. - return new VectorChatMessageStore(vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions); - } - }); + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are good at telling jokes.", + Name = "Joker", + ChatMessageStoreFactory = ctx => + { + // Create a new chat message store for this agent that stores the messages in a vector store. + // Each thread must get its own copy of the VectorChatMessageStore, since the store + // also contains the id that the thread is stored under. + return new VectorChatMessageStore(vectorStore, ctx.SerializedState, ctx.JsonSerializerOptions); + } + }); // Start a new thread for the agent conversation. AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj index 1f8c39c55fa..980e2826410 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Agent_Step08_Observability.csproj @@ -11,6 +11,7 @@ + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs index 2826eb06b09..c48242f5ca2 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step08_Observability/Program.cs @@ -4,6 +4,7 @@ using Azure.AI.OpenAI; using Azure.Identity; +using Azure.Monitor.OpenTelemetry.Exporter; using Microsoft.Agents.AI; using OpenAI; using OpenTelemetry; @@ -11,22 +12,24 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; +var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); // Create TracerProvider with console exporter // This will output the telemetry data to the console. string sourceName = Guid.NewGuid().ToString("N"); -using var tracerProvider = Sdk.CreateTracerProviderBuilder() +var tracerProviderBuilder = Sdk.CreateTracerProviderBuilder() .AddSource(sourceName) - .AddConsoleExporter() - .Build(); + .AddConsoleExporter(); +if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString)) +{ + tracerProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString); +} +using var tracerProvider = tracerProviderBuilder.Build(); // Create the agent, and enable OpenTelemetry instrumentation. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) .GetChatClient(deploymentName) - .CreateAIAgent(JokerInstructions, JokerName) + .CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker") .AsBuilder() .UseOpenTelemetry(sourceName: sourceName) .Build(); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs index 6f83fc26b9a..894c034eb0c 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step09_DependencyInjection/Program.cs @@ -18,9 +18,8 @@ HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); // Add agent options to the service collection. -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; -builder.Services.AddSingleton(new ChatClientAgentOptions(JokerInstructions, JokerName)); +builder.Services.AddSingleton( + new ChatClientAgentOptions(instructions: "You are good at telling jokes.", name: "Joker")); // Add a chat client to the service collection. builder.Services.AddKeyedChatClient("AzureOpenAI", (sp) => new AzureOpenAIClient( diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj index 3ff0cf550af..f0cdbfccc75 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Agent_Step10_AsMcpTool.csproj @@ -14,7 +14,7 @@ - + diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs index 267f10eed3d..16bc3cd51e4 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step10_AsMcpTool/Program.cs @@ -12,18 +12,14 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerDescription = "An agent that tells jokes."; -const string JokerInstructions = "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'."; - var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); // Create a server side persistent agent var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( model: deploymentName, - name: JokerName, - description: JokerDescription, - instructions: JokerInstructions); + instructions: "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", + name: "Joker", + description: "An agent that tells jokes."); // Retrieve the server side persistent agent as an AIAgent. AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs index e26848f06a9..cce53ef3c0b 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step12_AsFunctionTool/Program.cs @@ -31,8 +31,8 @@ static string GetWeather([Description("The location to get the weather for.")] s AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant who responds in French.", tools: [weatherAgent.AsAIFunction()]); + .GetChatClient(deploymentName) + .CreateAIAgent(instructions: "You are a helpful assistant who responds in French.", tools: [weatherAgent.AsAIFunction()]); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("What is the weather like in Amsterdam?")); diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs index ea59c84ba40..28a50cc7d7d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step14_Middleware/Program.cs @@ -10,7 +10,6 @@ using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.ChatClient; using Microsoft.Extensions.AI; // Get Azure AI Foundry configuration from environment variables diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs index a9f21339f69..7284efcc42b 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step15_Plugins/Program.cs @@ -27,16 +27,13 @@ IServiceProvider serviceProvider = services.BuildServiceProvider(); -const string AgentName = "Assistant"; -const string AgentInstructions = "You are a helpful assistant that helps people find information."; - AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent( - instructions: AgentInstructions, - name: AgentName, + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are a helpful assistant that helps people find information.", + name: "Assistant", tools: [.. serviceProvider.GetRequiredService().AsAITools()], services: serviceProvider); // Pass the service provider to the agent so it will be available to plugin functions to resolve dependencies. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs index 365bdca3710..590b5308d5f 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Program.cs @@ -14,20 +14,17 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; -const string JokerName = "Joker"; -const string JokerInstructions = "You are good at telling jokes."; - // Construct the agent, and provide a factory to create an in-memory chat message store with a reducer that keeps only the last 2 non-system messages. AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(new ChatClientAgentOptions - { - Name = JokerName, - Instructions = JokerInstructions, - ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(new MessageCountingChatReducer(2), ctx.SerializedState, ctx.JsonSerializerOptions) - }); + .GetChatClient(deploymentName) + .CreateAIAgent(new ChatClientAgentOptions + { + Instructions = "You are good at telling jokes.", + Name = "Joker", + ChatMessageStoreFactory = ctx => new InMemoryChatMessageStore(new MessageCountingChatReducer(2), ctx.SerializedState, ctx.JsonSerializerOptions) + }); AgentThread thread = agent.GetNewThread(); diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj index ececa3ea5a9..c5e06bc3822 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj @@ -13,7 +13,7 @@ - + diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj index e068b6745a3..389b504c508 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index eb1741a763a..32017af1949 100644 --- a/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -9,9 +9,6 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_MODEL_ID") ?? "gpt-4.1-mini"; -const string AgentName = "MicrosoftLearnAgent"; -const string AgentInstructions = "You answer questions by searching the Microsoft Learn content only."; - // Get a client to create/retrieve server side agents with. var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); @@ -24,8 +21,8 @@ // Create a server side persistent agent with the Azure.AI.Agents.Persistent SDK. var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( model: model, - name: AgentName, - instructions: AgentInstructions, + name: "MicrosoftLearnAgent", + instructions: "You answer questions by searching the Microsoft Learn content only.", tools: [mcpTool]); // Retrieve an already created server side persistent agent as an AIAgent. diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md index 82bb0299d04..e7249ac33dc 100644 --- a/dotnet/samples/GettingStarted/README.md +++ b/dotnet/samples/GettingStarted/README.md @@ -9,6 +9,7 @@ of the agent framework. |---|---| |[Agents](./Agents/README.md)|Step by step instructions for getting started with agents| |[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers| +|[A2A](./A2A/README.md)|Getting started with A2A (Agent-to-Agent) specific features| |[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents| |[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents| |[Workflow](./Workflows/README.md)|Getting started with Workflow| diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs index 600182974c6..be345b46566 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/CustomAgentExecutors/Program.cs @@ -6,7 +6,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowCustomAgentExecutorsSample; @@ -50,7 +49,7 @@ private static async Task Main() // Execute the workflow await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Create a slogan for a new electric SUV that is affordable and fun to drive."); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is SloganGeneratedEvent or FeedbackEvent) { @@ -107,10 +106,7 @@ internal sealed class SloganGeneratedEvent(SloganResult sloganResult) : Workflow /// 1. HandleAsync(string message): Handles the initial task to create a slogan. /// 2. HandleAsync(Feedback message): Handles feedback to improve the slogan. /// -internal sealed class SloganWriterExecutor - : ReflectingExecutor, - IMessageHandler, - IMessageHandler +internal sealed class SloganWriterExecutor : Executor { private readonly AIAgent _agent; private readonly AgentThread _thread; @@ -134,17 +130,21 @@ public SloganWriterExecutor(string id, IChatClient chatClient) : base(id) this._thread = this._agent.GetNewThread(); } - public async ValueTask HandleAsync(string message, IWorkflowContext context) + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync) + .AddHandler(this.HandleAsync); + + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { - var result = await this._agent.RunAsync(message, this._thread); + var result = await this._agent.RunAsync(message, this._thread, cancellationToken: cancellationToken); var sloganResult = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); - await context.AddEventAsync(new SloganGeneratedEvent(sloganResult)); + await context.AddEventAsync(new SloganGeneratedEvent(sloganResult), cancellationToken); return sloganResult; } - public async ValueTask HandleAsync(FeedbackResult message, IWorkflowContext context) + public async ValueTask HandleAsync(FeedbackResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { var feedbackMessage = $""" Here is the feedback on your previous slogan: @@ -155,10 +155,10 @@ public async ValueTask HandleAsync(FeedbackResult message, IWorkfl Please use this feedback to improve your slogan. """; - var result = await this._agent.RunAsync(feedbackMessage, this._thread); + var result = await this._agent.RunAsync(feedbackMessage, this._thread, cancellationToken: cancellationToken); var sloganResult = JsonSerializer.Deserialize(result.Text) ?? throw new InvalidOperationException("Failed to deserialize slogan result."); - await context.AddEventAsync(new SloganGeneratedEvent(sloganResult)); + await context.AddEventAsync(new SloganGeneratedEvent(sloganResult), cancellationToken); return sloganResult; } } @@ -175,7 +175,7 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve /// /// A custom executor that uses an AI agent to provide feedback on a slogan. /// -internal sealed class FeedbackExecutor : ReflectingExecutor, IMessageHandler +internal sealed class FeedbackExecutor : Executor { private readonly AIAgent _agent; private readonly AgentThread _thread; @@ -205,7 +205,7 @@ public FeedbackExecutor(string id, IChatClient chatClient) : base(id) this._thread = this._agent.GetNewThread(); } - public async ValueTask HandleAsync(SloganResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(SloganResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { var sloganMessage = $""" Here is a slogan for the task '{message.Task}': @@ -213,24 +213,24 @@ public async ValueTask HandleAsync(SloganResult message, IWorkflowContext contex Please provide feedback on this slogan, including comments, a rating from 1 to 10, and suggested actions for improvement. """; - var response = await this._agent.RunAsync(sloganMessage, this._thread); + var response = await this._agent.RunAsync(sloganMessage, this._thread, cancellationToken: cancellationToken); var feedback = JsonSerializer.Deserialize(response.Text) ?? throw new InvalidOperationException("Failed to deserialize feedback."); - await context.AddEventAsync(new FeedbackEvent(feedback)); + await context.AddEventAsync(new FeedbackEvent(feedback), cancellationToken); if (feedback.Rating >= this.MinimumRating) { - await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}"); + await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}", cancellationToken); return; } if (this._attempts >= this.MaxAttempts) { - await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}"); + await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}", cancellationToken); return; } - await context.SendMessageAsync(feedback); + await context.SendMessageAsync(feedback, cancellationToken: cancellationToken); this._attempts++; } } diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs index 9a9005e79d9..9f1de87438d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/FoundryAgent/Program.cs @@ -43,7 +43,7 @@ private static async Task Main() // The agents are wrapped as executors. When they receive messages, // they will cache the messages and only start processing when they receive a TurnToken. await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is AgentRunUpdateEvent executorComplete) { diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs index 49305d99089..16d4e57e694 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs @@ -35,7 +35,7 @@ private static async Task Main() var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); // Create the workflow and turn it into an agent - var workflow = await WorkflowHelper.GetWorkflowAsync(chatClient).ConfigureAwait(false); + var workflow = await WorkflowHelper.GetWorkflowAsync(chatClient); var agent = workflow.AsAgent("workflow-agent", "Workflow Agent"); var thread = agent.GetNewThread(); @@ -59,7 +59,7 @@ private static async Task Main() static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input) { Dictionary> buffer = []; - await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread).ConfigureAwait(false)) + await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread)) { if (update.MessageId is null) { diff --git a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs index 1da5e6932e3..82ebffa050b 100644 --- a/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowHelper.cs @@ -2,7 +2,6 @@ using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowAsAnAgentsSample; @@ -43,21 +42,22 @@ private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClie /// Executor that starts the concurrent processing by sending messages to the agents. /// private sealed class ConcurrentStartExecutor() : - ReflectingExecutor("ConcurrentStartExecutor"), - IMessageHandler> + Executor>("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. /// /// The user message to process /// Workflow context for accessing workflow services and adding events - public async ValueTask HandleAsync(List message, IWorkflowContext context) + /// The to monitor for cancellation requests. + /// The default is . + public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. - await context.SendMessageAsync(message); + await context.SendMessageAsync(message, cancellationToken: cancellationToken); // Broadcast the turn token to kick off the agents. - await context.SendMessageAsync(new TurnToken(emitEvents: true)); + await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken); } } @@ -65,8 +65,7 @@ public async ValueTask HandleAsync(List message, IWorkflowContext c /// Executor that aggregates the results from the concurrent agents. /// private sealed class ConcurrentAggregationExecutor() : - ReflectingExecutor("ConcurrentAggregationExecutor"), - IMessageHandler + Executor("ConcurrentAggregationExecutor") { private readonly List _messages = []; @@ -75,14 +74,16 @@ private sealed class ConcurrentAggregationExecutor() : /// /// The message from the agent /// Workflow context for accessing workflow services and adding events - public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + /// The to monitor for cancellation requests. + /// The default is . + public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._messages.Add(message); if (this._messages.Count == 2) { var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}")); - await context.YieldOutputAsync(formattedMessages); + await context.YieldOutputAsync(formattedMessages, cancellationToken); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs index e36d59f6d60..aedf37700df 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/Program.cs @@ -25,7 +25,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); + var workflow = await WorkflowHelper.GetWorkflowAsync(); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -33,9 +33,9 @@ private static async Task Main() // Execute the workflow and save checkpoints await using Checkpointed checkpointedRun = await InProcessExecution - .StreamAsync(workflow, NumberSignal.Init, checkpointManager) - .ConfigureAwait(false); - await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + .StreamAsync(workflow, NumberSignal.Init, checkpointManager); + + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync()) { if (evt is ExecutorCompletedEvent executorCompletedEvt) { @@ -67,16 +67,15 @@ private static async Task Main() Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}"); // Rehydrate a new workflow instance from a saved checkpoint and continue execution - var newWorkflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); + var newWorkflow = await WorkflowHelper.GetWorkflowAsync(); const int CheckpointIndex = 5; Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint."); CheckpointInfo savedCheckpoint = checkpoints[CheckpointIndex]; await using Checkpointed newCheckpointedRun = - await InProcessExecution.ResumeStreamAsync(newWorkflow, savedCheckpoint, checkpointManager, checkpointedRun.Run.RunId) - .ConfigureAwait(false); + await InProcessExecution.ResumeStreamAsync(newWorkflow, savedCheckpoint, checkpointManager, checkpointedRun.Run.RunId); - await foreach (WorkflowEvent evt in newCheckpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in newCheckpointedRun.Run.WatchStreamAsync()) { if (evt is ExecutorCompletedEvent executorCompletedEvt) { diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs index f911fa8a059..bf38f74ec8f 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndRehydrate/WorkflowHelper.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowCheckpointAndRehydrateSample; @@ -42,7 +41,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// -internal sealed class GuessNumberExecutor() : ReflectingExecutor("Guess"), IMessageHandler +internal sealed class GuessNumberExecutor() : Executor("Guess") { /// /// The lower bound of the guessing range. @@ -69,20 +68,20 @@ public GuessNumberExecutor(int lowerBound, int upperBound) : this() private int NextGuess => (this.LowerBound + this.UpperBound) / 2; - public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + public override async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default) { switch (message) { case NumberSignal.Init: - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Above: this.UpperBound = this.NextGuess - 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Below: this.LowerBound = this.NextGuess + 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; } } @@ -92,20 +91,20 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext contex /// This must be overridden to save any state that is needed to resume the executor. /// protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound)); + context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound), cancellationToken: cancellationToken); /// /// Restore the state of the executor from a checkpoint. /// This must be overridden to restore any state that was saved during checkpointing. /// protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey).ConfigureAwait(false); + (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey, cancellationToken: cancellationToken); } /// /// Executor that judges the guess and provides feedback. /// -internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; private int _tries; @@ -120,20 +119,20 @@ public JudgeExecutor(int targetNumber) : this() this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public override async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken: cancellationToken); } else if (message < this._targetNumber) { - await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Below, cancellationToken: cancellationToken); } else { - await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Above, cancellationToken: cancellationToken); } } @@ -142,12 +141,12 @@ public async ValueTask HandleAsync(int message, IWorkflowContext context) /// This must be overridden to save any state that is needed to resume the executor. /// protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(StateKey, this._tries); + context.QueueStateUpdateAsync(StateKey, this._tries, cancellationToken: cancellationToken); /// /// Restore the state of the executor from a checkpoint. /// This must be overridden to restore any state that was saved during checkpointing. /// protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + this._tries = await context.ReadStateAsync(StateKey, cancellationToken: cancellationToken); } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs index f7fb178c8b1..b4191b397f0 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/Program.cs @@ -24,7 +24,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); + var workflow = await WorkflowHelper.GetWorkflowAsync(); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -33,8 +33,8 @@ private static async Task Main() // Execute the workflow and save checkpoints await using Checkpointed checkpointedRun = await InProcessExecution .StreamAsync(workflow, NumberSignal.Init, checkpointManager) - .ConfigureAwait(false); - await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + ; + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync()) { if (evt is ExecutorCompletedEvent executorCompletedEvt) { @@ -70,8 +70,8 @@ private static async Task Main() Console.WriteLine($"\n\nRestoring from the {CheckpointIndex + 1}th checkpoint."); CheckpointInfo savedCheckpoint = checkpoints[CheckpointIndex]; // Note that we are restoring the state directly to the same run instance. - await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false); - await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync()) { if (evt is ExecutorCompletedEvent executorCompletedEvt) { diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs index 8bfabfbf4dd..5f60b355d1d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointAndResume/WorkflowHelper.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowCheckpointAndResumeSample; @@ -42,7 +41,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// -internal sealed class GuessNumberExecutor() : ReflectingExecutor("Guess"), IMessageHandler +internal sealed class GuessNumberExecutor() : Executor("Guess") { /// /// The lower bound of the guessing range. @@ -69,20 +68,20 @@ public GuessNumberExecutor(int lowerBound, int upperBound) : this() private int NextGuess => (this.LowerBound + this.UpperBound) / 2; - public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + public override async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default) { switch (message) { case NumberSignal.Init: - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Above: this.UpperBound = this.NextGuess - 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Below: this.LowerBound = this.NextGuess + 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; } } @@ -92,20 +91,20 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext contex /// This must be overridden to save any state that is needed to resume the executor. /// protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound)); + context.QueueStateUpdateAsync(StateKey, (this.LowerBound, this.UpperBound), cancellationToken: cancellationToken); /// /// Restore the state of the executor from a checkpoint. /// This must be overridden to restore any state that was saved during checkpointing. /// protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey).ConfigureAwait(false); + (this.LowerBound, this.UpperBound) = await context.ReadStateAsync<(int, int)>(StateKey, cancellationToken: cancellationToken); } /// /// Executor that judges the guess and provides feedback. /// -internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; private int _tries; @@ -120,20 +119,20 @@ public JudgeExecutor(int targetNumber) : this() this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public override async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken); } else if (message < this._targetNumber) { - await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Below, cancellationToken: cancellationToken); } else { - await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Above, cancellationToken: cancellationToken); } } @@ -142,12 +141,12 @@ public async ValueTask HandleAsync(int message, IWorkflowContext context) /// This must be overridden to save any state that is needed to resume the executor. /// protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(StateKey, this._tries); + context.QueueStateUpdateAsync(StateKey, this._tries, cancellationToken: cancellationToken); /// /// Restore the state of the executor from a checkpoint. /// This must be overridden to restore any state that was saved during checkpointing. /// protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + this._tries = await context.ReadStateAsync(StateKey, cancellationToken: cancellationToken); } diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs index 48903425a92..0a968ed8b37 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/Program.cs @@ -27,7 +27,7 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); + var workflow = await WorkflowHelper.GetWorkflowAsync(); // Create checkpoint manager var checkpointManager = CheckpointManager.Default; @@ -36,15 +36,15 @@ private static async Task Main() // Execute the workflow and save checkpoints await using Checkpointed checkpointedRun = await InProcessExecution .StreamAsync(workflow, new SignalWithNumber(NumberSignal.Init), checkpointManager) - .ConfigureAwait(false); - await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + ; + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync()) { switch (evt) { case RequestInfoEvent requestInputEvt: // Handle `RequestInfoEvent` from the workflow ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); - await checkpointedRun.Run.SendResponseAsync(response).ConfigureAwait(false); + await checkpointedRun.Run.SendResponseAsync(response); break; case ExecutorCompletedEvent executorCompletedEvt: Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); @@ -76,15 +76,15 @@ private static async Task Main() Console.WriteLine($"\n\nRestoring from the {CheckpointIndex + 1}th checkpoint."); CheckpointInfo savedCheckpoint = checkpoints[CheckpointIndex]; // Note that we are restoring the state directly to the same run instance. - await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false); - await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync().ConfigureAwait(false)) + await checkpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None); + await foreach (WorkflowEvent evt in checkpointedRun.Run.WatchStreamAsync()) { switch (evt) { case RequestInfoEvent requestInputEvt: // Handle `RequestInfoEvent` from the workflow ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); - await checkpointedRun.Run.SendResponseAsync(response).ConfigureAwait(false); + await checkpointedRun.Run.SendResponseAsync(response); break; case ExecutorCompletedEvent executorCompletedEvt: Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed."); diff --git a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs index 42f77ae3874..a4dcfcf3760 100644 --- a/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowHelper.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowCheckpointWithHumanInTheLoopSample; @@ -54,7 +53,7 @@ public SignalWithNumber(NumberSignal signal, int? number = null) /// /// Executor that judges the guess and provides feedback. /// -internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; private int _tries; @@ -69,21 +68,20 @@ public JudgeExecutor(int targetNumber) : this() this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public override async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") - .ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken); } else if (message < this._targetNumber) { - await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Below, message)).ConfigureAwait(false); + await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Below, message), cancellationToken: cancellationToken); } else { - await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Above, message)).ConfigureAwait(false); + await context.SendMessageAsync(new SignalWithNumber(NumberSignal.Above, message), cancellationToken: cancellationToken); } } @@ -92,12 +90,12 @@ await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tri /// This must be overridden to save any state that is needed to resume the executor. /// protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - context.QueueStateUpdateAsync(StateKey, this._tries); + context.QueueStateUpdateAsync(StateKey, this._tries, cancellationToken: cancellationToken); /// /// Restore the state of the executor from a checkpoint. /// This must be overridden to restore any state that was saved during checkpointing. /// protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => - this._tries = await context.ReadStateAsync(StateKey).ConfigureAwait(false); + this._tries = await context.ReadStateAsync(StateKey, cancellationToken: cancellationToken); } diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index d43474ece01..30b2372006c 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -4,7 +4,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowConcurrentSample; @@ -60,7 +59,7 @@ private static async Task Main() // Execute the workflow in streaming mode await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, "What is temperature?"); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is WorkflowOutputEvent output) { @@ -74,22 +73,23 @@ private static async Task Main() /// Executor that starts the concurrent processing by sending messages to the agents. /// internal sealed class ConcurrentStartExecutor() : - ReflectingExecutor("ConcurrentStartExecutor"), - IMessageHandler + Executor("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. /// /// The user message to process /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// A task representing the asynchronous operation - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. - await context.SendMessageAsync(new ChatMessage(ChatRole.User, message)); + await context.SendMessageAsync(new ChatMessage(ChatRole.User, message), cancellationToken: cancellationToken); // Broadcast the turn token to kick off the agents. - await context.SendMessageAsync(new TurnToken(emitEvents: true)); + await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken); } } @@ -97,8 +97,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) /// Executor that aggregates the results from the concurrent agents. /// internal sealed class ConcurrentAggregationExecutor() : - ReflectingExecutor("ConcurrentAggregationExecutor"), - IMessageHandler + Executor("ConcurrentAggregationExecutor") { private readonly List _messages = []; @@ -107,15 +106,17 @@ internal sealed class ConcurrentAggregationExecutor() : /// /// The message from the agent /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// A task representing the asynchronous operation - public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._messages.Add(message); if (this._messages.Count == 2) { var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.AuthorName}: {m.Text}")); - await context.YieldOutputAsync(formattedMessages); + await context.YieldOutputAsync(formattedMessages, cancellationToken); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs index a0f059a00ec..db10f0e8af3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/MapReduce/Program.cs @@ -5,9 +5,9 @@ using System.IO; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowMapReduceSample; @@ -129,8 +129,7 @@ private static async Task RunWorkflowAsync(Workflow workflow) /// Splits data into roughly equal chunks based on the number of mapper nodes. /// internal sealed class Split(string[] mapperIds, string id) : - ReflectingExecutor(id), - IMessageHandler + Executor(id) { private readonly string[] _mapperIds = mapperIds; private static readonly string[] s_lineSeparators = ["\r\n", "\r", "\n"]; @@ -138,7 +137,7 @@ internal sealed class Split(string[] mapperIds, string id) : /// /// Tokenize input and assign contiguous index ranges to each mapper via shared state. /// - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Ensure temp directory exists Directory.CreateDirectory(MapReduceConstants.TempDir); @@ -147,7 +146,7 @@ public async ValueTask HandleAsync(string message, IWorkflowContext context) var wordList = Preprocess(message); // Store the tokenized words once so that all mappers can read by index - await context.QueueStateUpdateAsync(MapReduceConstants.DataToProcessKey, wordList, scopeName: MapReduceConstants.StateScope); + await context.QueueStateUpdateAsync(MapReduceConstants.DataToProcessKey, wordList, scopeName: MapReduceConstants.StateScope, cancellationToken); // Divide indices into contiguous slices for each mapper var mapperCount = this._mapperIds.Length; @@ -160,10 +159,10 @@ async Task ProcessChunkAsync(int i) var endIndex = i < mapperCount - 1 ? startIndex + chunkSize : wordList.Length; // Save the indices under the mapper's Id - await context.QueueStateUpdateAsync(this._mapperIds[i], (startIndex, endIndex), scopeName: MapReduceConstants.StateScope); + await context.QueueStateUpdateAsync(this._mapperIds[i], (startIndex, endIndex), scopeName: MapReduceConstants.StateScope, cancellationToken); // Notify the mapper that data is ready - await context.SendMessageAsync(new SplitComplete(), targetId: this._mapperIds[i]); + await context.SendMessageAsync(new SplitComplete(), targetId: this._mapperIds[i], cancellationToken); } // Process all the chunks @@ -187,15 +186,15 @@ private static string[] Preprocess(string data) /// /// Maps each token to a count of 1 and writes pairs to a per-mapper file. /// -internal sealed class Mapper(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class Mapper(string id) : Executor(id) { /// /// Read the assigned slice, emit (word, 1) pairs, and persist to disk. /// - public async ValueTask HandleAsync(SplitComplete message, IWorkflowContext context) + public override async ValueTask HandleAsync(SplitComplete message, IWorkflowContext context, CancellationToken cancellationToken = default) { - var dataToProcess = await context.ReadStateAsync(MapReduceConstants.DataToProcessKey, scopeName: MapReduceConstants.StateScope); - var chunk = await context.ReadStateAsync<(int start, int end)>(this.Id, scopeName: MapReduceConstants.StateScope); + var dataToProcess = await context.ReadStateAsync(MapReduceConstants.DataToProcessKey, scopeName: MapReduceConstants.StateScope, cancellationToken); + var chunk = await context.ReadStateAsync<(int start, int end)>(this.Id, scopeName: MapReduceConstants.StateScope, cancellationToken); var results = dataToProcess![chunk.start..chunk.end] .Select(word => (word, 1)) @@ -204,9 +203,9 @@ public async ValueTask HandleAsync(SplitComplete message, IWorkflowContext conte // Write this mapper's results as simple text lines for easy debugging var filePath = Path.Combine(MapReduceConstants.TempDir, $"map_results_{this.Id}.txt"); var lines = results.Select(r => $"{r.word}: {r.Item2}"); - await File.WriteAllLinesAsync(filePath, lines); + await File.WriteAllLinesAsync(filePath, lines, cancellationToken); - await context.SendMessageAsync(new MapComplete(filePath)); + await context.SendMessageAsync(new MapComplete(filePath), cancellationToken: cancellationToken); } } @@ -214,8 +213,7 @@ public async ValueTask HandleAsync(SplitComplete message, IWorkflowContext conte /// Groups intermediate pairs by key and partitions them across reducers. /// internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string id) : - ReflectingExecutor(id), - IMessageHandler + Executor(id) { private readonly string[] _reducerIds = reducerIds; private readonly string[] _mapperIds = mapperIds; @@ -224,7 +222,7 @@ internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string i /// /// Aggregate mapper outputs and write one partition file per reducer. /// - public async ValueTask HandleAsync(MapComplete message, IWorkflowContext context) + public override async ValueTask HandleAsync(MapComplete message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._mapResults.Add(message); @@ -241,9 +239,9 @@ async Task ProcessChunkAsync(List<(string key, List values)> chunk, int ind // Write one grouped partition for reducer index and notify that reducer var filePath = Path.Combine(MapReduceConstants.TempDir, $"shuffle_results_{index}.txt"); var lines = chunk.Select(kvp => $"{kvp.key}: {JsonSerializer.Serialize(kvp.values)}"); - await File.WriteAllLinesAsync(filePath, lines); + await File.WriteAllLinesAsync(filePath, lines, cancellationToken); - await context.SendMessageAsync(new ShuffleComplete(filePath, this._reducerIds[index])); + await context.SendMessageAsync(new ShuffleComplete(filePath, this._reducerIds[index]), cancellationToken: cancellationToken); } var tasks = chunks.Select((chunk, i) => ProcessChunkAsync(chunk, i)); @@ -313,12 +311,12 @@ async Task ProcessChunkAsync(List<(string key, List values)> chunk, int ind /// /// Sums grouped counts per key for its assigned partition. /// -internal sealed class Reducer(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class Reducer(string id) : Executor(id) { /// /// Read one shuffle partition and reduce it to totals. /// - public async ValueTask HandleAsync(ShuffleComplete message, IWorkflowContext context) + public override async ValueTask HandleAsync(ShuffleComplete message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.ReducerId != this.Id) { @@ -327,7 +325,7 @@ public async ValueTask HandleAsync(ShuffleComplete message, IWorkflowContext con } // Read grouped values from the shuffle output - var lines = await File.ReadAllLinesAsync(message.FilePath); + var lines = await File.ReadAllLinesAsync(message.FilePath, cancellationToken); // Sum values per key. Values are serialized JSON arrays like [1, 1, ...] var reducedResults = new Dictionary(); @@ -345,9 +343,9 @@ public async ValueTask HandleAsync(ShuffleComplete message, IWorkflowContext con // Persist our partition totals var filePath = Path.Combine(MapReduceConstants.TempDir, $"reduced_results_{this.Id}.txt"); var outputLines = reducedResults.Select(kvp => $"{kvp.Key}: {kvp.Value}"); - await File.WriteAllLinesAsync(filePath, outputLines); + await File.WriteAllLinesAsync(filePath, outputLines, cancellationToken); - await context.SendMessageAsync(new ReduceComplete(filePath)); + await context.SendMessageAsync(new ReduceComplete(filePath), cancellationToken: cancellationToken); } } @@ -355,16 +353,15 @@ public async ValueTask HandleAsync(ShuffleComplete message, IWorkflowContext con /// Joins all reducer outputs and yields the final output. /// internal sealed class CompletionExecutor(string id) : - ReflectingExecutor(id), - IMessageHandler> + Executor>(id) { /// /// Collect reducer output file paths and yield final output. /// - public async ValueTask HandleAsync(List message, IWorkflowContext context) + public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) { var filePaths = message.ConvertAll(r => r.FilePath); - await context.YieldOutputAsync(filePaths); + await context.YieldOutputAsync(filePaths, cancellationToken); } } diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs index 65ac2aa852e..b6e3d4d5136 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -6,7 +6,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowEdgeConditionSample; @@ -64,7 +63,7 @@ private static async Task Main() // Execute the workflow await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is WorkflowOutputEvent outputEvent) { @@ -147,7 +146,7 @@ internal sealed class Email /// /// Executor that detects spam using an AI agent. /// -internal sealed class SpamDetectionExecutor : ReflectingExecutor, IMessageHandler +internal sealed class SpamDetectionExecutor : Executor { private readonly AIAgent _spamDetectionAgent; @@ -160,7 +159,7 @@ public SpamDetectionExecutor(AIAgent spamDetectionAgent) : base("SpamDetectionEx this._spamDetectionAgent = spamDetectionAgent; } - public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Generate a random email ID and store the email content to the shared state var newEmail = new Email @@ -168,10 +167,10 @@ public async ValueTask HandleAsync(ChatMessage message, IWorkfl EmailId = Guid.NewGuid().ToString("N"), EmailContent = message.Text }; - await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._spamDetectionAgent.RunAsync(message); + var response = await this._spamDetectionAgent.RunAsync(message, cancellationToken: cancellationToken); var detectionResult = JsonSerializer.Deserialize(response.Text); detectionResult!.EmailId = newEmail.EmailId; @@ -192,7 +191,7 @@ public sealed class EmailResponse /// /// Executor that assists with email responses using an AI agent. /// -internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +internal sealed class EmailAssistantExecutor : Executor { private readonly AIAgent _emailAssistantAgent; @@ -205,7 +204,7 @@ public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistan this._emailAssistantAgent = emailAssistantAgent; } - public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.IsSpam) { @@ -213,11 +212,11 @@ public async ValueTask HandleAsync(DetectionResult message, IWork } // Retrieve the email content from the shared state - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope) + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken) ?? throw new InvalidOperationException("Email not found."); // Invoke the agent - var response = await this._emailAssistantAgent.RunAsync(email.EmailContent); + var response = await this._emailAssistantAgent.RunAsync(email.EmailContent, cancellationToken: cancellationToken); var emailResponse = JsonSerializer.Deserialize(response.Text); return emailResponse!; @@ -227,28 +226,28 @@ public async ValueTask HandleAsync(DetectionResult message, IWork /// /// Executor that sends emails. /// -internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// /// Simulate the sending of an email. /// - public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.YieldOutputAsync($"Email sent: {message.Response}"); + public override async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) => + await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken); } /// /// Executor that handles spam messages. /// -internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// /// Simulate the handling of a spam message. /// - public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.IsSpam) { - await context.YieldOutputAsync($"Email marked as spam: {message.Reason}"); + await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken); } else { diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs index 4f985f47a2f..13f0a75bc2f 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/02_SwitchCase/Program.cs @@ -6,7 +6,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowSwitchCaseSample; @@ -80,7 +79,7 @@ private static async Task Main() // Execute the workflow await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is WorkflowOutputEvent outputEvent) { @@ -172,7 +171,7 @@ internal sealed class Email /// /// Executor that detects spam using an AI agent. /// -internal sealed class SpamDetectionExecutor : ReflectingExecutor, IMessageHandler +internal sealed class SpamDetectionExecutor : Executor { private readonly AIAgent _spamDetectionAgent; @@ -185,7 +184,7 @@ public SpamDetectionExecutor(AIAgent spamDetectionAgent) : base("SpamDetectionEx this._spamDetectionAgent = spamDetectionAgent; } - public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Generate a random email ID and store the email content var newEmail = new Email @@ -193,10 +192,10 @@ public async ValueTask HandleAsync(ChatMessage message, IWorkfl EmailId = Guid.NewGuid().ToString("N"), EmailContent = message.Text }; - await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._spamDetectionAgent.RunAsync(message); + var response = await this._spamDetectionAgent.RunAsync(message, cancellationToken: cancellationToken); var detectionResult = JsonSerializer.Deserialize(response.Text); detectionResult!.EmailId = newEmail.EmailId; @@ -217,7 +216,7 @@ public sealed class EmailResponse /// /// Executor that assists with email responses using an AI agent. /// -internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +internal sealed class EmailAssistantExecutor : Executor { private readonly AIAgent _emailAssistantAgent; @@ -230,7 +229,7 @@ public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistan this._emailAssistantAgent = emailAssistantAgent; } - public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Spam) { @@ -238,10 +237,10 @@ public async ValueTask HandleAsync(DetectionResult message, IWork } // Retrieve the email content from the context - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent); + var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken); var emailResponse = JsonSerializer.Deserialize(response.Text); return emailResponse!; @@ -251,28 +250,28 @@ public async ValueTask HandleAsync(DetectionResult message, IWork /// /// Executor that sends emails. /// -internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// /// Simulate the sending of an email. /// - public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.YieldOutputAsync($"Email sent: {message.Response}").ConfigureAwait(false); + public override async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) => + await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken); } /// /// Executor that handles spam messages. /// -internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// /// Simulate the handling of a spam message. /// - public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Spam) { - await context.YieldOutputAsync($"Email marked as spam: {message.Reason}").ConfigureAwait(false); + await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken); } else { @@ -284,17 +283,17 @@ public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext con /// /// Executor that handles uncertain emails. /// -internal sealed class HandleUncertainExecutor() : ReflectingExecutor("HandleUncertainExecutor"), IMessageHandler +internal sealed class HandleUncertainExecutor() : Executor("HandleUncertainExecutor") { /// /// Simulate the handling of an uncertain spam decision. /// - public async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(DetectionResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Uncertain) { - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); - await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"); + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); + await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}", cancellationToken); } else { diff --git a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs index 987dd7ffd69..15746f727e6 100644 --- a/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -6,7 +6,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using Microsoft.Extensions.AI; namespace WorkflowMultiSelectionSample; @@ -88,7 +87,7 @@ private static async Task Main() // Execute the workflow await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, email)); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is WorkflowOutputEvent outputEvent) { @@ -228,7 +227,7 @@ internal sealed class Email /// /// Executor that analyzes emails using an AI agent. /// -internal sealed class EmailAnalysisExecutor : ReflectingExecutor, IMessageHandler +internal sealed class EmailAnalysisExecutor : Executor { private readonly AIAgent _emailAnalysisAgent; @@ -241,7 +240,7 @@ public EmailAnalysisExecutor(AIAgent emailAnalysisAgent) : base("EmailAnalysisEx this._emailAnalysisAgent = emailAnalysisAgent; } - public async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Generate a random email ID and store the email content var newEmail = new Email @@ -249,10 +248,10 @@ public async ValueTask HandleAsync(ChatMessage message, IWorkflo EmailId = Guid.NewGuid().ToString("N"), EmailContent = message.Text }; - await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope); + await context.QueueStateUpdateAsync(newEmail.EmailId, newEmail, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._emailAnalysisAgent.RunAsync(message); + var response = await this._emailAnalysisAgent.RunAsync(message, cancellationToken: cancellationToken); var AnalysisResult = JsonSerializer.Deserialize(response.Text); AnalysisResult!.EmailId = newEmail.EmailId; @@ -274,7 +273,7 @@ public sealed class EmailResponse /// /// Executor that assists with email responses using an AI agent. /// -internal sealed class EmailAssistantExecutor : ReflectingExecutor, IMessageHandler +internal sealed class EmailAssistantExecutor : Executor { private readonly AIAgent _emailAssistantAgent; @@ -287,7 +286,7 @@ public EmailAssistantExecutor(AIAgent emailAssistantAgent) : base("EmailAssistan this._emailAssistantAgent = emailAssistantAgent; } - public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Spam) { @@ -295,10 +294,10 @@ public async ValueTask HandleAsync(AnalysisResult message, IWorkf } // Retrieve the email content from the context - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent); + var response = await this._emailAssistantAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken); var emailResponse = JsonSerializer.Deserialize(response.Text); return emailResponse!; @@ -308,28 +307,28 @@ public async ValueTask HandleAsync(AnalysisResult message, IWorkf /// /// Executor that sends emails. /// -internal sealed class SendEmailExecutor() : ReflectingExecutor("SendEmailExecutor"), IMessageHandler +internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// /// Simulate the sending of an email. /// - public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) => - await context.YieldOutputAsync($"Email sent: {message.Response}"); + public override async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) => + await context.YieldOutputAsync($"Email sent: {message.Response}", cancellationToken); } /// /// Executor that handles spam messages. /// -internal sealed class HandleSpamExecutor() : ReflectingExecutor("HandleSpamExecutor"), IMessageHandler +internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// /// Simulate the handling of a spam message. /// - public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Spam) { - await context.YieldOutputAsync($"Email marked as spam: {message.Reason}"); + await context.YieldOutputAsync($"Email marked as spam: {message.Reason}", cancellationToken); } else { @@ -341,17 +340,17 @@ public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext cont /// /// Executor that handles uncertain messages. /// -internal sealed class HandleUncertainExecutor() : ReflectingExecutor("HandleUncertainExecutor"), IMessageHandler +internal sealed class HandleUncertainExecutor() : Executor("HandleUncertainExecutor") { /// /// Simulate the handling of an uncertain spam decision. /// - public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message.spamDecision == SpamDecision.Uncertain) { - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); - await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"); + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); + await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}", cancellationToken); } else { @@ -372,7 +371,7 @@ public sealed class EmailSummary /// /// Executor that summarizes emails using an AI agent. /// -internal sealed class EmailSummaryExecutor : ReflectingExecutor, IMessageHandler +internal sealed class EmailSummaryExecutor : Executor { private readonly AIAgent _emailSummaryAgent; @@ -385,13 +384,13 @@ public EmailSummaryExecutor(AIAgent emailSummaryAgent) : base("EmailSummaryExecu this._emailSummaryAgent = emailSummaryAgent; } - public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Read the email content from the shared states - var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); + var email = await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); // Invoke the agent - var response = await this._emailSummaryAgent.RunAsync(email!.EmailContent); + var response = await this._emailSummaryAgent.RunAsync(email!.EmailContent, cancellationToken: cancellationToken); var emailSummary = JsonSerializer.Deserialize(response.Text); message.EmailSummary = emailSummary!.Summary; @@ -408,19 +407,19 @@ internal sealed class DatabaseEvent(string message) : WorkflowEvent(message) { } /// /// Executor that handles database access. /// -internal sealed class DatabaseAccessExecutor() : ReflectingExecutor("DatabaseAccessExecutor"), IMessageHandler +internal sealed class DatabaseAccessExecutor() : Executor("DatabaseAccessExecutor") { - public async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(AnalysisResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { // 1. Save the email content - await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope); - await Task.Delay(100); // Simulate database access delay + await context.ReadStateAsync(message.EmailId, scopeName: EmailStateConstants.EmailStateScope, cancellationToken); + await Task.Delay(100, cancellationToken); // Simulate database access delay // 2. Save the analysis result - await Task.Delay(100); // Simulate database access delay + await Task.Delay(100, cancellationToken); // Simulate database access delay // Not using the `WorkflowCompletedEvent` because this is not the end of the workflow. // The end of the workflow is signaled by the `SendEmailExecutor` or the `HandleUnknownExecutor`. - await context.AddEventAsync(new DatabaseEvent($"Email {message.EmailId} saved to database.")); + await context.AddEventAsync(new DatabaseEvent($"Email {message.EmailId} saved to database."), cancellationToken); } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs index e0671409127..d1c8d45082d 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Generated.cs @@ -52,22 +52,22 @@ await this.InitializeEnvironmentAsync( "FOUNDRY_AGENT_RESEARCHWEATHER").ConfigureAwait(false); // Initialize variables - await context.QueueStateUpdateAsync("AgentResponse", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("AgentResponseText", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("AvailableAgents", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("FinalResponse", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("InputTask", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("InternalConversationId", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("NextSpeaker", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("Plan", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("ProgressLedgerUpdate", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("RestartCount", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("SeedTask", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("StallCount", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("TaskFacts", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("TaskInstructions", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("TeamDescription", UnassignedValue.Instance, "Local").ConfigureAwait(false); - await context.QueueStateUpdateAsync("TypedProgressLedger", UnassignedValue.Instance, "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync("AgentResponse", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("AgentResponseText", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("AvailableAgents", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("FinalResponse", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("InputTask", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("InternalConversationId", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("NextSpeaker", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("Plan", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("ProgressLedgerUpdate", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("RestartCount", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("SeedTask", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("StallCount", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("TaskFacts", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("TaskInstructions", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("TeamDescription", UnassignedValue.Instance, "Local"); + await context.QueueStateUpdateAsync("TypedProgressLedger", UnassignedValue.Instance, "Local"); } } @@ -97,8 +97,8 @@ internal sealed class SetvariableAaslmfExecutor(FormulaSession session) : Action agentid: Env.FOUNDRY_AGENT_RESEARCHWEB } ] - """).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "AvailableAgents", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + """); + await context.QueueStateUpdateAsync(key: "AvailableAgents", value: evaluatedValue, scopeName: "Local"); return default; } @@ -115,8 +115,8 @@ internal sealed class SetvariableV6yeboExecutor(FormulaSession session) : Action object? evaluatedValue = await context.EvaluateValueAsync(""" Concat(ForAll(Local.AvailableAgents, $"- " & name & $": " & description), Value, " ") - """).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TeamDescription", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + """); + await context.QueueStateUpdateAsync(key: "TeamDescription", value: evaluatedValue, scopeName: "Local"); return default; } @@ -130,8 +130,8 @@ internal sealed class SetvariableNz2u0lExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("System.LastMessage.Text").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "InputTask", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("System.LastMessage.Text"); + await context.QueueStateUpdateAsync(key: "InputTask", value: evaluatedValue, scopeName: "Local"); return default; } @@ -145,8 +145,8 @@ internal sealed class Setvariable10U2znExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("UserMessage(Local.InputTask)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "SeedTask", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("UserMessage(Local.InputTask)"); + await context.QueueStateUpdateAsync(key: "SeedTask", value: evaluatedValue, scopeName: "Local"); return default; } @@ -167,7 +167,7 @@ Analyzing facts... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -180,8 +180,8 @@ internal sealed class Conversation1A2b3cExecutor(FormulaSession session, Workflo { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "InternalConversationId", value: conversationId, scopeName: "Local").ConfigureAwait(false); + string conversationId = await agentProvider.CreateConversationAsync(cancellationToken); + await context.QueueStateUpdateAsync(key: "InternalConversationId", value: conversationId, scopeName: "Local"); return default; } @@ -195,14 +195,14 @@ internal sealed class QuestionUdomuwExecutor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -226,7 +226,7 @@ 4. EDUCATED GUESSES DO NOT include any other headings or sections in your response. DO NOT list next steps or plans until asked to do so. """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)").ConfigureAwait(false); + IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)"); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -236,14 +236,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -264,7 +264,7 @@ Creating a plan... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -278,14 +278,14 @@ internal sealed class QuestionDsbajuExecutor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -300,7 +300,7 @@ Your only job is to devise an efficient plan that identifies (by name) how a tea Remember, there is no requirement to involve the entire team -- only select team member's whose particular expertise is required for this task. """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)").ConfigureAwait(false); + IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.InputTask)"); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -310,14 +310,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -354,8 +354,8 @@ internal sealed class SetvariableKk2ldlExecutor(FormulaSession session) : Action Here is the plan to follow as best as possible: " & Last(Local.Plan).Text - """).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + """); + await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local"); return default; } @@ -376,7 +376,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -390,14 +390,14 @@ internal sealed class QuestionO3bqkfExecutor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -443,7 +443,7 @@ await context.FormatTemplateAsync( }} }} """); - IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.AgentResponseText)").ConfigureAwait(false); + IList? inputMessages = await context.EvaluateListAsync("UserMessage(Local.AgentResponseText)"); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -453,14 +453,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "ProgressLedgerUpdate", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "ProgressLedgerUpdate", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -496,8 +496,8 @@ internal sealed class ParseRnztlvExecutor(FormulaSession session) : ActionExecut VariableType.Record( ("reason", typeof(string)), ("answer", typeof(string))))); - object? parsedValue = await context.ConvertValueAsync(targetType, "Last(Local.ProgressLedgerUpdate).Text", cancellationToken).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TypedProgressLedger", value: parsedValue, scopeName: "Local").ConfigureAwait(false); + object? parsedValue = await context.ConvertValueAsync(targetType, "Last(Local.ProgressLedgerUpdate).Text", cancellationToken); + await context.QueueStateUpdateAsync(key: "TypedProgressLedger", value: parsedValue, scopeName: "Local"); return default; } @@ -511,13 +511,13 @@ internal sealed class ConditiongroupMvieccExecutor(FormulaSession session) : Act // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_request_satisfied.answer").ConfigureAwait(false); + bool condition0 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_request_satisfied.answer"); if (condition0) { return "conditionItem_fj432c"; } - bool condition1 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_in_loop.answer || Not(Local.TypedProgressLedger.is_progress_being_made.answer)").ConfigureAwait(false); + bool condition1 = await context.EvaluateValueAsync("Local.TypedProgressLedger.is_in_loop.answer || Not(Local.TypedProgressLedger.is_progress_being_made.answer)"); if (condition1) { return "conditionItem_yiqund"; @@ -542,7 +542,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -556,14 +556,14 @@ internal sealed class QuestionKe3l1dExecutor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -572,7 +572,7 @@ We have completed the task. Based only on the conversation and without adding any new information, synthesize the result of the conversation as a complete response to the user task. The user will only every see this last response and not the entire conversation, so please ensure it is complete and self-contained. """); - IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local").ConfigureAwait(false); + IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local"); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -582,14 +582,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "FinalResponse", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "FinalResponse", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -603,8 +603,8 @@ internal sealed class SetvariableH5lxddExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1"); + await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); return default; } @@ -618,13 +618,13 @@ internal sealed class ConditiongroupVbtqd3Executor(FormulaSession session) : Act // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync(".TypedProgressLedger.is_in_loop.answer").ConfigureAwait(false); + bool condition0 = await context.EvaluateValueAsync(".TypedProgressLedger.is_in_loop.answer"); if (condition0) { return "conditionItem_fpaNL9"; } - bool condition1 = await context.EvaluateValueAsync("Not(Local.TypedProgressLedger.is_progress_being_made.answer)").ConfigureAwait(false); + bool condition1 = await context.EvaluateValueAsync("Not(Local.TypedProgressLedger.is_progress_being_made.answer)"); if (condition1) { return "conditionItem_NnqvXh"; @@ -649,7 +649,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -670,7 +670,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -684,7 +684,7 @@ internal sealed class ConditiongroupXznrdmExecutor(FormulaSession session) : Act // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Local.StallCount > 2").ConfigureAwait(false); + bool condition0 = await context.EvaluateValueAsync("Local.StallCount > 2"); if (condition0) { return "conditionItem_NlQTBv"; @@ -709,7 +709,7 @@ Unable to make sufficient progress... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -723,7 +723,7 @@ internal sealed class Conditiongroup4S1z27Executor(FormulaSession session) : Act // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("Local.RestartCount > 2").ConfigureAwait(false); + bool condition0 = await context.EvaluateValueAsync("Local.RestartCount > 2"); if (condition0) { return "conditionItem_EXAlhZ"; @@ -748,7 +748,7 @@ Stopping after attempting {Local.RestartCount} restarts... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -769,7 +769,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -783,14 +783,14 @@ internal sealed class QuestionWfj123Executor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHANALYST", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -810,7 +810,7 @@ await context.FormatTemplateAsync( "As a reminder, we are working to solve the following task: " & Local.InputTask) - """).ConfigureAwait(false); + """); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -820,14 +820,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "TaskFacts", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -848,7 +848,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -862,14 +862,14 @@ internal sealed class QuestionUej456Executor(FormulaSession session, WorkflowAge // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env").ConfigureAwait(false); + string? agentName = await context.ReadStateAsync(key: "FOUNDRY_AGENT_RESEARCHMANAGER", scopeName: "Env"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "InternalConversationId", scopeName: "Local"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( @@ -891,14 +891,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "Plan", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -935,8 +935,8 @@ internal sealed class SetvariableJw7tmmExecutor(FormulaSession session) : Action Here is the plan to follow as best as possible: " & Local.Plan.Text - """).ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + """); + await context.QueueStateUpdateAsync(key: "TaskInstructions", value: evaluatedValue, scopeName: "Local"); return default; } @@ -951,7 +951,7 @@ internal sealed class Setvariable6J2snpExecutor(FormulaSession session) : Action protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { object? evaluatedValue = 0; - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); return default; } @@ -965,8 +965,8 @@ internal sealed class SetvariableS6hcghExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.RestartCount + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "RestartCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("Local.RestartCount + 1"); + await context.QueueStateUpdateAsync(key: "RestartCount", value: evaluatedValue, scopeName: "Local"); return default; } @@ -989,7 +989,7 @@ await context.FormatTemplateAsync( """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -1004,7 +1004,7 @@ internal sealed class SetvariableL7ooqoExecutor(FormulaSession session) : Action protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { object? evaluatedValue = 0; - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); return default; } @@ -1018,8 +1018,8 @@ internal sealed class SetvariableNxn1meExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Search(Local.AvailableAgents, Local.TypedProgressLedger.next_speaker.answer, name)").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "NextSpeaker", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("Search(Local.AvailableAgents, Local.TypedProgressLedger.next_speaker.answer, name)"); + await context.QueueStateUpdateAsync(key: "NextSpeaker", value: evaluatedValue, scopeName: "Local"); return default; } @@ -1033,7 +1033,7 @@ internal sealed class ConditiongroupQfpif5Executor(FormulaSession session) : Act // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - bool condition0 = await context.EvaluateValueAsync("CountRows(Local.NextSpeaker) = 1").ConfigureAwait(false); + bool condition0 = await context.EvaluateValueAsync("CountRows(Local.NextSpeaker) = 1"); if (condition0) { return "conditionItem_GmigcU"; @@ -1051,21 +1051,21 @@ internal sealed class QuestionOrsbf06Executor(FormulaSession session, WorkflowAg // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - string? agentName = await context.EvaluateValueAsync("First(Local.NextSpeaker).agentid").ConfigureAwait(false); + string? agentName = await context.EvaluateValueAsync("First(Local.NextSpeaker).agentid"); if (string.IsNullOrWhiteSpace(agentName)) { throw new InvalidOperationException($"Agent name must be defined: {this.Id}"); } - string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System").ConfigureAwait(false); + string? conversationId = await context.ReadStateAsync(key: "ConversationId", scopeName: "System"); bool autoSend = true; string additionalInstructions = await context.FormatTemplateAsync( """ {Local.TypedProgressLedger.instruction_or_question.answer} """); - IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local").ConfigureAwait(false); + IList? inputMessages = await context.ReadListAsync(key: "SeedTask", scopeName: "Local"); AgentRunResponse agentResponse = await InvokeAgentAsync( @@ -1075,14 +1075,14 @@ await InvokeAgentAsync( autoSend, additionalInstructions, inputMessages, - cancellationToken).ConfigureAwait(false); + cancellationToken); if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, agentResponse)); } - await context.QueueStateUpdateAsync(key: "AgentResponse", value: agentResponse.Messages, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "AgentResponse", value: agentResponse.Messages, scopeName: "Local"); return default; } @@ -1096,8 +1096,8 @@ internal sealed class SetvariableXznrdmExecutor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Last(Local.AgentResponse).Text").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "AgentResponseText", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("Last(Local.AgentResponse).Text"); + await context.QueueStateUpdateAsync(key: "AgentResponseText", value: evaluatedValue, scopeName: "Local"); return default; } @@ -1111,7 +1111,7 @@ internal sealed class Setvariable8Eix2aExecutor(FormulaSession session) : Action { protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - await context.QueueStateUpdateAsync(key: "SeedTask", value: UnassignedValue.Instance, scopeName: "Local").ConfigureAwait(false); + await context.QueueStateUpdateAsync(key: "SeedTask", value: UnassignedValue.Instance, scopeName: "Local"); return default; } @@ -1132,7 +1132,7 @@ Unable to choose next agent... """ ); AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]); - await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(this.Id, response)); return default; } @@ -1146,8 +1146,8 @@ internal sealed class SetvariableBhcsi7Executor(FormulaSession session) : Action // protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken) { - object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1").ConfigureAwait(false); - await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local").ConfigureAwait(false); + object? evaluatedValue = await context.EvaluateValueAsync("Local.StallCount + 1"); + await context.QueueStateUpdateAsync(key: "StallCount", value: evaluatedValue, scopeName: "Local"); return default; } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs index ca8032f8089..7837194d326 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteCode/Program.cs @@ -76,102 +76,103 @@ private async Task MonitorAndDisposeWorkflowRunAsync(StreamingRun run) string? messageId = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) { - if (evt is ExecutorInvokedEvent executorInvoked) + switch (workflowEvent) { - Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}"); - } - else if (evt is ExecutorCompletedEvent executorComplete) - { - Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}"); - } - else if (evt is ExecutorFailedEvent executorFailure) - { - Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); - } - else if (evt is WorkflowErrorEvent workflowError) - { - Debug.WriteLine("WORKFLOW ERROR"); - } - else if (evt is ConversationUpdateEvent invokeEvent) - { - Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); - } - else if (evt is AgentRunUpdateEvent streamEvent) - { - if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) - { - messageId = streamEvent.Update.MessageId; + case ExecutorInvokedEvent executorInvoked: + Debug.WriteLine($"STEP ENTER #{executorInvoked.ExecutorId}"); + break; + + case ExecutorCompletedEvent executorComplete: + Debug.WriteLine($"STEP EXIT #{executorComplete.ExecutorId}"); + break; + + case ExecutorFailedEvent executorFailure: + Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); + break; + + case WorkflowErrorEvent workflowError: + throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure..."); + + case ConversationUpdateEvent invokeEvent: + Debug.WriteLine($"CONVERSATION: {invokeEvent.Data}"); + break; - if (messageId is not null) + case AgentRunUpdateEvent streamEvent: + if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) { - string? agentId = streamEvent.Update.AuthorName; - if (agentId is not null) + messageId = streamEvent.Update.MessageId; + + if (messageId is not null) { - if (!s_nameCache.TryGetValue(agentId, out string? realName)) + string? agentId = streamEvent.Update.AuthorName; + if (agentId is not null) { - PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); - s_nameCache[agentId] = agent.Name; - realName = agent.Name; + if (!s_nameCache.TryGetValue(agentId, out string? realName)) + { + PersistentAgent agent = await this.FoundryClient.Administration.GetAgentAsync(agentId); + s_nameCache[agentId] = agent.Name; + realName = agent.Name; + } + agentId = realName; } - agentId = realName; + agentId ??= nameof(ChatRole.Assistant); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($"\n{agentId.ToUpperInvariant()}:"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{messageId}]"); } - agentId ??= nameof(ChatRole.Assistant); - Console.ForegroundColor = ConsoleColor.Cyan; - Console.Write($"\n{agentId.ToUpperInvariant()}:"); - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($" [{messageId}]"); } - } - - ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; - switch (chatUpdate?.RawRepresentation) - { - case MessageContentUpdate messageUpdate: - string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; - if (fileId is not null && s_fileCache.Add(fileId)) - { - BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); - await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); - } - break; - } - try - { - Console.ResetColor(); - Console.Write(streamEvent.Data); - } - finally - { - Console.ResetColor(); - } - } - else if (evt is AgentRunResponseEvent messageEvent) - { - try - { - Console.WriteLine(); - if (messageEvent.Response.AgentId is null) + + ChatResponseUpdate? chatUpdate = streamEvent.Update.RawRepresentation as ChatResponseUpdate; + switch (chatUpdate?.RawRepresentation) { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("ACTIVITY:"); - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(messageEvent.Response?.Text.Trim()); + case MessageContentUpdate messageUpdate: + string? fileId = messageUpdate.ImageFileId ?? messageUpdate.TextAnnotation?.OutputFileId; + if (fileId is not null && s_fileCache.Add(fileId)) + { + BinaryData content = await this.FoundryClient.Files.GetFileContentAsync(fileId); + await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); + } + break; } - else + try { - if (messageEvent.Response.Usage is not null) + Console.ResetColor(); + Console.Write(streamEvent.Data); + } + finally + { + Console.ResetColor(); + } + break; + + case AgentRunResponseEvent messageEvent: + try + { + Console.WriteLine(); + if (messageEvent.Response.AgentId is null) { - Console.ForegroundColor = ConsoleColor.DarkGray; - Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("ACTIVITY:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(messageEvent.Response?.Text.Trim()); + } + else + { + if (messageEvent.Response.Usage is not null) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($"[Tokens Total: {messageEvent.Response.Usage.TotalTokenCount}, Input: {messageEvent.Response.Usage.InputTokenCount}, Output: {messageEvent.Response.Usage.OutputTokenCount}]"); + } } } - } - finally - { - Console.ResetColor(); - } + finally + { + Console.ResetColor(); + } + break; } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs index f9754ab22e1..dcb52219081 100644 --- a/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Declarative/ExecuteWorkflow/Program.cs @@ -1,12 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. +// Uncomment this to enable JSON checkpointing to the local file system. +//#define CHECKPOINT_JSON + using System.Diagnostics; using System.Reflection; +using System.Text.Json; using Azure.AI.Agents.Persistent; using Azure.Identity; using Microsoft.Agents.AI.Workflows; +#if CHECKPOINT_JSON +using Microsoft.Agents.AI.Workflows.Checkpointing; +#endif using Microsoft.Agents.AI.Workflows.Declarative; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -57,15 +65,23 @@ private async Task ExecuteAsync() // Run the workflow, just like any other workflow string input = this.GetWorkflowInput(); +#if CHECKPOINT_JSON + // Use a file-system based JSON checkpoint store to persist checkpoints to disk. + DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}")); + CheckpointManager checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder)); +#else + // Use an in-memory checkpoint store that will not persist checkpoints beyond the lifetime of the process. CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); +#endif + Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, checkpointManager); bool isComplete = false; - InputResponse? response = null; + object? response = null; do { - ExternalRequest? inputRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response); - if (inputRequest is not null) + ExternalRequest? externalRequest = await this.MonitorAndDisposeWorkflowRunAsync(run, response); + if (externalRequest is not null) { Notify("\nWORKFLOW: Yield"); @@ -75,7 +91,7 @@ private async Task ExecuteAsync() } // Process the external request. - response = HandleExternalRequest(inputRequest); + response = await this.HandleExternalRequestAsync(externalRequest); // Let's resume on an entirely new workflow instance to demonstrate checkpoint portability. workflow = this.CreateWorkflow(); @@ -96,11 +112,25 @@ private async Task ExecuteAsync() Notify("\nWORKFLOW: Done!\n"); } + /// + /// Create the workflow from the declarative YAML. Includes definition of the + /// and the associated . + /// + /// + /// The value assigned to controls on whether the function + /// tools () initialized in the constructor are included for auto-invocation. + /// private Workflow CreateWorkflow() { // Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file. + AzureAgentProvider agentProvider = new(this.FoundryEndpoint, new AzureCliCredential()) + { + // Functions included here will be auto-executed by the framework. + Functions = IncludeFunctions ? this.FunctionMap.Values : null, + }; + DeclarativeWorkflowOptions options = - new(new AzureAgentProvider(this.FoundryEndpoint, new AzureCliCredential())) + new(agentProvider) { Configuration = this.Configuration, //ConversationId = null, // Assign to continue a conversation @@ -110,8 +140,18 @@ private Workflow CreateWorkflow() return DeclarativeWorkflowBuilder.Build(this.WorkflowFile, options); } + /// + /// Configuration key used to identify the Foundry project endpoint. + /// private const string ConfigKeyFoundryEndpoint = "FOUNDRY_PROJECT_ENDPOINT"; + /// + /// Controls on whether the function tools () initialized + /// in the constructor are included for auto-invocation. + /// NOTE: By default, no functions exist as part of this sample. + /// + private const bool IncludeFunctions = true; + private static Dictionary NameCache { get; } = []; private static HashSet FileCache { get; } = []; @@ -121,6 +161,7 @@ private Workflow CreateWorkflow() private PersistentAgentsClient FoundryClient { get; } private IConfiguration Configuration { get; } private CheckpointInfo? LastCheckpoint { get; set; } + private Dictionary FunctionMap { get; } private Program(string workflowFile, string? workflowInput) { @@ -131,15 +172,24 @@ private Program(string workflowFile, string? workflowInput) this.FoundryEndpoint = this.Configuration[ConfigKeyFoundryEndpoint] ?? throw new InvalidOperationException($"Undefined configuration setting: {ConfigKeyFoundryEndpoint}"); this.FoundryClient = new PersistentAgentsClient(this.FoundryEndpoint, new AzureCliCredential()); + + List functions = + [ + // Manually define any custom functions that may be required by agents within the workflow. + // By default, this sample does not include any functions. + //AIFunctionFactory.Create(), + ]; + this.FunctionMap = functions.ToDictionary(f => f.Name); } - private async Task MonitorAndDisposeWorkflowRunAsync(Checkpointed run, InputResponse? response = null) + private async Task MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) { await using IAsyncDisposable disposeRun = run; + bool hasStreamed = false; string? messageId = null; - await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent workflowEvent in run.Run.WatchStreamAsync()) { switch (workflowEvent) { @@ -163,6 +213,9 @@ private Program(string workflowFile, string? workflowInput) Debug.WriteLine($"STEP ERROR #{executorFailure.ExecutorId}: {executorFailure.Data?.Message ?? "Unknown"}"); break; + case WorkflowErrorEvent workflowError: + throw workflowError.Data as Exception ?? new InvalidOperationException("Unexpected failure..."); + case SuperStepCompletedEvent checkpointCompleted: this.LastCheckpoint = checkpointCompleted.CompletionInfo?.Checkpoint; Debug.WriteLine($"CHECKPOINT x{checkpointCompleted.StepNumber} [{this.LastCheckpoint?.CheckpointId ?? "(none)"}]"); @@ -173,12 +226,12 @@ private Program(string workflowFile, string? workflowInput) if (response is not null) { ExternalResponse requestResponse = requestInfo.Request.CreateResponse(response); - await run.Run.SendResponseAsync(requestResponse).ConfigureAwait(false); + await run.Run.SendResponseAsync(requestResponse); response = null; } else { - await run.Run.DisposeAsync().ConfigureAwait(false); + await run.Run.DisposeAsync(); return requestInfo.Request; } break; @@ -197,11 +250,12 @@ private Program(string workflowFile, string? workflowInput) case AgentRunUpdateEvent streamEvent: if (!string.Equals(messageId, streamEvent.Update.MessageId, StringComparison.Ordinal)) { + hasStreamed = false; messageId = streamEvent.Update.MessageId; if (messageId is not null) { - string? agentId = streamEvent.Update.AuthorName; + string? agentId = streamEvent.Update.AgentId; if (agentId is not null) { if (!NameCache.TryGetValue(agentId, out string? realName)) @@ -231,11 +285,18 @@ private Program(string workflowFile, string? workflowInput) await DownloadFileContentAsync(Path.GetFileName(messageUpdate.TextAnnotation?.TextToReplace ?? "response.png"), content); } break; + case RequiredActionUpdate actionUpdate: + Console.ForegroundColor = ConsoleColor.White; + Console.Write($"Calling tool: {actionUpdate.FunctionName}"); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [{actionUpdate.ToolCallId}]"); + break; } try { Console.ResetColor(); - Console.Write(streamEvent.Data); + Console.Write(streamEvent.Update.Text); + hasStreamed |= !string.IsNullOrEmpty(streamEvent.Update.Text); } finally { @@ -246,7 +307,11 @@ private Program(string workflowFile, string? workflowInput) case AgentRunResponseEvent messageEvent: try { - Console.WriteLine(); + if (hasStreamed) + { + Console.WriteLine(); + } + if (messageEvent.Response.Usage is not null) { Console.ForegroundColor = ConsoleColor.DarkGray; @@ -263,14 +328,31 @@ private Program(string workflowFile, string? workflowInput) return default; } - private static InputResponse HandleExternalRequest(ExternalRequest request) + + /// + /// Handle request for external input, either from a human or a function tool invocation. + /// + private async ValueTask HandleExternalRequestAsync(ExternalRequest request) => + request.Data.TypeId.TypeName switch + { + // Request for human input + _ when request.Data.TypeId.IsMatch() => HandleInputRequest(request.DataAs()!), + // Request for function tool invocation. (Only active when functions are defined and IncludeFunctions is true.) + _ when request.Data.TypeId.IsMatch() => await this.HandleToolRequestAsync(request.DataAs()!), + // Unknown request type. + _ => throw new InvalidOperationException($"Unsupported external request type: {request.GetType().Name}."), + }; + + /// + /// Handle request for human input. + /// + private static InputResponse HandleInputRequest(InputRequest request) { - InputRequest? message = request.Data.As(); string? userInput; do { Console.ForegroundColor = ConsoleColor.DarkGreen; - Console.Write($"\n{message?.Prompt ?? "INPUT:"} "); + Console.Write($"\n{request.Prompt ?? "INPUT:"} "); Console.ForegroundColor = ConsoleColor.White; userInput = Console.ReadLine(); } @@ -279,6 +361,30 @@ private static InputResponse HandleExternalRequest(ExternalRequest request) return new InputResponse(userInput); } + /// + /// Handle a function tool request by invoking the specified tools and returning the results. + /// + /// + /// This handler is only active when is set to true and + /// one or more instances are defined in the constructor. + /// + private async ValueTask HandleToolRequestAsync(AgentToolRequest request) + { + Task[] functionTasks = request.FunctionCalls.Select(functionCall => InvokesToolAsync(functionCall)).ToArray(); + + await Task.WhenAll(functionTasks); + + return AgentToolResponse.Create(request, functionTasks.Select(task => task.Result)); + + async Task InvokesToolAsync(FunctionCallContent functionCall) + { + AIFunction functionTool = this.FunctionMap[functionCall.Name]; + AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues()); + object? result = await functionTool.InvokeAsync(functionArguments); + return new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result)); + } + } + private static string? ParseWorkflowFile(string[] args) { string? workflowFile = args.FirstOrDefault(); diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs index e3628b11e51..62f1925fb30 100644 --- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/Program.cs @@ -24,18 +24,18 @@ public static class Program private static async Task Main() { // Create the workflow - var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false); + var workflow = await WorkflowHelper.GetWorkflowAsync(); // Execute the workflow - await using StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); - await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) + await using StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init); + await foreach (WorkflowEvent evt in handle.WatchStreamAsync()) { switch (evt) { case RequestInfoEvent requestInputEvt: // Handle `RequestInfoEvent` from the workflow ExternalResponse response = HandleExternalRequest(requestInputEvt.Request); - await handle.SendResponseAsync(response).ConfigureAwait(false); + await handle.SendResponseAsync(response); break; case WorkflowOutputEvent outputEvt: diff --git a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs index 8f0f8c7b352..c87ddc00cbf 100644 --- a/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs +++ b/dotnet/samples/GettingStarted/Workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowHelper.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowHumanInTheLoopBasicSample; @@ -39,7 +38,7 @@ internal enum NumberSignal /// /// Executor that judges the guess and provides feedback. /// -internal sealed class JudgeExecutor() : ReflectingExecutor("Judge"), IMessageHandler +internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; private int _tries; @@ -53,21 +52,20 @@ public JudgeExecutor(int targetNumber) : this() this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public override async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") - .ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken); } else if (message < this._targetNumber) { - await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Below, cancellationToken: cancellationToken); } else { - await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Above, cancellationToken: cancellationToken); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs index 020c193ca0a..15d930216fd 100644 --- a/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Loop/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowLoopSample; @@ -33,8 +32,8 @@ private static async Task Main() .BuildAsync(); // Execute the workflow - await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is WorkflowOutputEvent outputEvent) { @@ -57,7 +56,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// -internal sealed class GuessNumberExecutor : ReflectingExecutor, IMessageHandler +internal sealed class GuessNumberExecutor : Executor { /// /// The lower bound of the guessing range. @@ -83,20 +82,20 @@ public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id) private int NextGuess => (this.LowerBound + this.UpperBound) / 2; - public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + public override async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default) { switch (message) { case NumberSignal.Init: - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Above: this.UpperBound = this.NextGuess - 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; case NumberSignal.Below: this.LowerBound = this.NextGuess + 1; - await context.SendMessageAsync(this.NextGuess).ConfigureAwait(false); + await context.SendMessageAsync(this.NextGuess, cancellationToken: cancellationToken); break; } } @@ -105,7 +104,7 @@ public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext contex /// /// Executor that judges the guess and provides feedback. /// -internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler +internal sealed class JudgeExecutor : Executor { private readonly int _targetNumber; private int _tries; @@ -120,21 +119,21 @@ public JudgeExecutor(string id, int targetNumber) : base(id) this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public override async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!") - .ConfigureAwait(false); + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken) + ; } else if (message < this._targetNumber) { - await context.SendMessageAsync(NumberSignal.Below).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Below, cancellationToken: cancellationToken); } else { - await context.SendMessageAsync(NumberSignal.Above).ConfigureAwait(false); + await context.SendMessageAsync(NumberSignal.Above, cancellationToken: cancellationToken); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj new file mode 100644 index 00000000000..f7a5a4424f7 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj @@ -0,0 +1,21 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs new file mode 100644 index 00000000000..f7894f707a0 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Observability/ApplicationInsights/Program.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.Agents.AI.Workflows; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace WorkflowObservabilitySample; + +/// +/// This sample shows how to enable observability in a workflow and send the traces +/// to be visualized in Application Insights. +/// +/// In this example, we create a simple text processing pipeline that: +/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor +/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor +/// +/// The executors are connected sequentially, so data flows from one to the next in order. +/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH". +/// +public static class Program +{ + private const string SourceName = "Workflow.ApplicationInsightsSample"; + private static readonly ActivitySource s_activitySource = new(SourceName); + + private static async Task Main() + { + var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING") ?? throw new InvalidOperationException("APPLICATIONINSIGHTS_CONNECTION_STRING is not set."); + + var resourceBuilder = ResourceBuilder + .CreateDefault() + .AddService("WorkflowSample"); + + using var traceProvider = Sdk.CreateTracerProviderBuilder() + .SetResourceBuilder(resourceBuilder) + .AddSource("Microsoft.Agents.AI.Workflows*") + .AddSource(SourceName) + .AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString) + .Build(); + + // Start a root activity for the application + using var activity = s_activitySource.StartActivity("main"); + Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}"); + + // Create the executors + UppercaseExecutor uppercase = new(); + ReverseTextExecutor reverse = new(); + + // Build the workflow by connecting executors sequentially + var workflow = new WorkflowBuilder(uppercase) + .AddEdge(uppercase, reverse) + .Build(); + + // Execute the workflow with input data + Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!"); + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompletedEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } + } + } +} + +/// +/// First executor: converts input text to uppercase. +/// +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") +{ + /// + /// Processes the input message by converting it to uppercase. + /// + /// The input text to convert + /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . + /// The input text converted to uppercase + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + message.ToUpperInvariant(); // The return value will be sent as a message along an edge to subsequent executors +} + +/// +/// Second executor: reverses the input text and completes the workflow. +/// +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor") +{ + /// + /// Processes the input message by reversing the text. + /// + /// The input text to reverse + /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . + /// The input text reversed + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + => new(message.Reverse().ToArray()); +} diff --git a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs index c02d6bc4a0b..c04a397c559 100644 --- a/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Observability/AspireDashboard/Program.cs @@ -2,7 +2,6 @@ using System.Diagnostics; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; @@ -71,28 +70,33 @@ private static async Task Main() /// /// First executor: converts input text to uppercase. /// -internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") { /// /// Processes the input message by converting it to uppercase. /// /// The input text to convert /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text converted to uppercase - public async ValueTask HandleAsync(string message, IWorkflowContext context) => + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => message.ToUpperInvariant(); // The return value will be sent as a message along an edge to subsequent executors } /// /// Second executor: reverses the input text and completes the workflow. /// -internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor") { /// /// Processes the input message by reversing the text. /// /// The input text to reverse /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text reversed - public async ValueTask HandleAsync(string message, IWorkflowContext context) => new string(message.Reverse().ToArray()); + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + => new(message.Reverse().ToArray()); } diff --git a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs index 8812d2689e0..6f4cfdf38ba 100644 --- a/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/SharedStates/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowSharedStatesSample; @@ -52,15 +51,15 @@ internal static class FileContentStateConstants public const string FileContentStateScope = "FileContentState"; } -internal sealed class FileReadExecutor() : ReflectingExecutor("FileReadExecutor"), IMessageHandler +internal sealed class FileReadExecutor() : Executor("FileReadExecutor") { - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Read file content from embedded resource string fileContent = Resources.Read(message); // Store file content in a shared state for access by other executors string fileID = Guid.NewGuid().ToString("N"); - await context.QueueStateUpdateAsync(fileID, fileContent, scopeName: FileContentStateConstants.FileContentStateScope); + await context.QueueStateUpdateAsync(fileID, fileContent, scopeName: FileContentStateConstants.FileContentStateScope, cancellationToken); return fileID; } @@ -72,12 +71,12 @@ internal sealed class FileStats public int WordCount { get; set; } } -internal sealed class WordCountingExecutor() : ReflectingExecutor("WordCountingExecutor"), IMessageHandler +internal sealed class WordCountingExecutor() : Executor("WordCountingExecutor") { - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Retrieve the file content from the shared state - var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope) + var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope, cancellationToken) ?? throw new InvalidOperationException("File content state not found"); int wordCount = fileContent.Split([' ', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; @@ -86,12 +85,12 @@ public async ValueTask HandleAsync(string message, IWorkflowContext c } } -internal sealed class ParagraphCountingExecutor() : ReflectingExecutor("ParagraphCountingExecutor"), IMessageHandler +internal sealed class ParagraphCountingExecutor() : Executor("ParagraphCountingExecutor") { - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Retrieve the file content from the shared state - var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope) + var fileContent = await context.ReadStateAsync(message, scopeName: FileContentStateConstants.FileContentStateScope, cancellationToken) ?? throw new InvalidOperationException("File content state not found"); int paragraphCount = fileContent.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries).Length; @@ -100,11 +99,11 @@ public async ValueTask HandleAsync(string message, IWorkflowContext c } } -internal sealed class AggregationExecutor() : ReflectingExecutor("AggregationExecutor"), IMessageHandler +internal sealed class AggregationExecutor() : Executor("AggregationExecutor") { private readonly List _messages = []; - public async ValueTask HandleAsync(FileStats message, IWorkflowContext context) + public override async ValueTask HandleAsync(FileStats message, IWorkflowContext context, CancellationToken cancellationToken = default) { this._messages.Add(message); @@ -113,7 +112,7 @@ public async ValueTask HandleAsync(FileStats message, IWorkflowContext context) // Aggregate the results from both executors var totalParagraphCount = this._messages.Sum(m => m.ParagraphCount); var totalWordCount = this._messages.Sum(m => m.WordCount); - await context.YieldOutputAsync($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}"); + await context.YieldOutputAsync($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}", cancellationToken); } } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs index b3712eed11f..68e2effd049 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/01_ExecutorsAndEdges/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowExecutorsAndEdgesSample; @@ -44,32 +43,36 @@ private static async Task Main() /// /// First executor: converts input text to uppercase. /// -internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") { /// /// Processes the input message by converting it to uppercase. /// /// The input text to convert /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text converted to uppercase - public async ValueTask HandleAsync(string message, IWorkflowContext context) => - message.ToUpperInvariant(); // The return value will be sent as a message along an edge to subsequent executors + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors } /// /// Second executor: reverses the input text and completes the workflow. /// -internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor") { /// /// Processes the input message by reversing the text. /// /// The input text to reverse /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text reversed - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Because we do not suppress it, the returned result will be yielded as an output from this executor. - return string.Concat(message.Reverse()); + return ValueTask.FromResult(string.Concat(message.Reverse())); } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs index c3c1370b846..021f191a5e4 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/02_Streaming/Program.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Reflection; namespace WorkflowStreamingSample; @@ -30,7 +29,7 @@ private static async Task Main() // Execute the workflow in streaming mode await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!"); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is ExecutorCompletedEvent executorCompleted) { @@ -43,32 +42,36 @@ private static async Task Main() /// /// First executor: converts input text to uppercase. /// -internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") { /// /// Processes the input message by converting it to uppercase. /// /// The input text to convert /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text converted to uppercase - public async ValueTask HandleAsync(string message, IWorkflowContext context) => - message.ToUpperInvariant(); // The return value will be sent as a message along an edge to subsequent executors + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors } /// /// Second executor: reverses the input text and completes the workflow. /// -internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor") { /// /// Processes the input message by reversing the text. /// /// The input text to reverse /// Workflow context for accessing workflow services and adding events + /// The to monitor for cancellation requests. + /// The default is . /// The input text reversed - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Because we do not suppress it, the returned result will be yielded as an output from this executor. - return string.Concat(message.Reverse()); + return ValueTask.FromResult(string.Concat(message.Reverse())); } } diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs index 2b30f3f5a14..0a8ee0d6ee8 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/03_AgentsInWorkflows/Program.cs @@ -50,7 +50,7 @@ private static async Task Main() // The agents are wrapped as executors. When they receive messages, // they will cache the messages and only start processing when they receive a TurnToken. await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { if (evt is AgentRunUpdateEvent executorComplete) { diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs index aeb06cfb649..8cc66ed18a8 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/04_AgentWorkflowPatterns/Program.cs @@ -70,7 +70,7 @@ await RunWorkflowAsync( case "groupchat": await RunWorkflowAsync( - AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 }) + AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = 5 }) .AddParticipants(from lang in (string[])["French", "Spanish", "English"] select GetTranslationAgent(lang, client)) .Build(), [new(ChatRole.User, "Hello, world!")]); @@ -86,7 +86,7 @@ static async Task> RunWorkflowAsync(Workflow workflow, List Fact-Checker -> Reporter AIAgent workflowAgent = await AgentWorkflowBuilder.BuildSequential(researcher, factChecker, reporter) - .AsAgentAsync() - .ConfigureAwait(false); + .AsAgentAsync(); // Run the workflow, streaming the output as it arrives. string? lastAuthor = null; diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj new file mode 100644 index 00000000000..1ef94de3daa --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj @@ -0,0 +1,17 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs new file mode 100644 index 00000000000..de00c35ae8b --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/Program.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; + +namespace WorkflowSubWorkflowsSample; + +/// +/// This sample demonstrates how to compose workflows hierarchically by using +/// a workflow as an executor within another workflow (sub-workflows). +/// +/// A sub-workflow is a workflow that is embedded as an executor within a parent workflow. +/// This allows you to: +/// 1. Encapsulate and reuse complex workflow logic as modular components +/// 2. Build hierarchical workflow structures +/// 3. Create composable, maintainable workflow architectures +/// +/// In this example, we create: +/// - A text processing sub-workflow (uppercase → reverse → append suffix) +/// - A parent workflow that adds a prefix, processes through the sub-workflow, and post-processes +/// +/// For input "hello", the workflow produces: "INPUT: [FINAL] OLLEH [PROCESSED] [END]" +/// +public static class Program +{ + private static async Task Main() + { + Console.WriteLine("\n=== Sub-Workflow Demonstration ===\n"); + + // Step 1: Build a simple text processing sub-workflow + Console.WriteLine("Building sub-workflow: Uppercase → Reverse → Append Suffix...\n"); + + UppercaseExecutor uppercase = new(); + ReverseExecutor reverse = new(); + AppendSuffixExecutor append = new(" [PROCESSED]"); + + var subWorkflow = new WorkflowBuilder(uppercase) + .AddEdge(uppercase, reverse) + .AddEdge(reverse, append) + .WithOutputFrom(append) + .Build(); + + // Step 2: Configure the sub-workflow as an executor for use in the parent workflow + ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow"); + + // Step 3: Build a main workflow that uses the sub-workflow as an executor + Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n"); + + PrefixExecutor prefix = new("INPUT: "); + PostProcessExecutor postProcess = new(); + + var mainWorkflow = new WorkflowBuilder(prefix) + .AddEdge(prefix, subWorkflowExecutor) + .AddEdge(subWorkflowExecutor, postProcess) + .WithOutputFrom(postProcess) + .Build(); + + // Step 4: Execute the main workflow + Console.WriteLine("Executing main workflow with input: 'hello'\n"); + await using Run run = await InProcessExecution.RunAsync(mainWorkflow, "hello"); + + // Display results + foreach (WorkflowEvent evt in run.NewEvents) + { + if (evt is ExecutorCompletedEvent executorComplete && executorComplete.Data is not null) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine($"[{executorComplete.ExecutorId}] {executorComplete.Data}"); + Console.ResetColor(); + } + else if (evt is WorkflowOutputEvent output) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("\n=== Main Workflow Completed ==="); + Console.WriteLine($"Final Output: {output.Data}"); + Console.ResetColor(); + } + } + + // Optional: Visualize the workflow structure - Note that sub-workflows are not rendered + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine("\n=== Workflow Visualization ===\n"); + Console.WriteLine(mainWorkflow.ToMermaidString()); + Console.ResetColor(); + + Console.WriteLine("\n✅ Sample Complete: Workflows can be composed hierarchically using sub-workflows\n"); + } +} + +// ==================================== +// Text Processing Executors +// ==================================== + +/// +/// Adds a prefix to the input text. +/// +internal sealed class PrefixExecutor(string prefix) : Executor("PrefixExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string result = prefix + message; + Console.WriteLine($"[Prefix] '{message}' → '{result}'"); + return ValueTask.FromResult(result); + } +} + +/// +/// Converts input text to uppercase. +/// +internal sealed class UppercaseExecutor() : Executor("UppercaseExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string result = message.ToUpperInvariant(); + Console.WriteLine($"[Uppercase] '{message}' → '{result}'"); + return ValueTask.FromResult(result); + } +} + +/// +/// Reverses the input text. +/// +internal sealed class ReverseExecutor() : Executor("ReverseExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string result = string.Concat(message.Reverse()); + Console.WriteLine($"[Reverse] '{message}' → '{result}'"); + return ValueTask.FromResult(result); + } +} + +/// +/// Appends a suffix to the input text. +/// +internal sealed class AppendSuffixExecutor(string suffix) : Executor("AppendSuffixExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string result = message + suffix; + Console.WriteLine($"[AppendSuffix] '{message}' → '{result}'"); + return ValueTask.FromResult(result); + } +} + +/// +/// Performs final post-processing by wrapping the text. +/// +internal sealed class PostProcessExecutor() : Executor("PostProcessExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + string result = $"[FINAL] {message} [END]"; + Console.WriteLine($"[PostProcess] '{message}' → '{result}'"); + return ValueTask.FromResult(result); + } +} diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index a6fab54b416..d6f2f5c39ca 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -18,7 +18,7 @@ The samples are subdivided into the following categories: `AIAgent` and can be used with any underlying service that provides an `AIAgent` implementation. - [Getting Started - Agent Providers](./GettingStarted/AgentProviders/README.md): Shows how to create an AIAgent instance for a selection of providers. - [Getting Started - Agent Telemetry](./GettingStarted/AgentOpenTelemetry/README.md): Demo which showcases the integration of OpenTelemetry with the Microsoft Agent Framework using Azure OpenAI and .NET Aspire Dashboard for telemetry visualization. -- [Semantic Kernel Migration](./SemanticKernelMigration/): Semantic Kernel to Agent Framework migration guide +- [Semantic Kernel to Agent Framework Migration](https://github.com/microsoft/semantic-kernel/tree/main/dotnet/samples/AgentFrameworkMigration): For instructions and samples describing how to migrate from Semantic Kernel to Microsoft Agent Framework ## Prerequisites diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj deleted file mode 100644 index a548ac03d00..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/AgentOrchestrations_Step01_Concurrent.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net9.0 - enable - enable - $(NoWarn);CA1812;RCS1102;CA1707;VSTHRD200 - true - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/Program.cs b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/Program.cs deleted file mode 100644 index ac6c2764349..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step01_Concurrent/Program.cs +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.Orchestration; -using Microsoft.SemanticKernel.Agents.Orchestration.Concurrent; -using Microsoft.SemanticKernel.Agents.Runtime.InProcess; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -var agentInstructions = "You are a translation assistant who only responds in {0}. Respond to any input by outputting the name of the input language and then translating the input to {0}."; - -// This sample compares running concurrent orchestrations using -// Semantic Kernel and the Agent Framework. -Console.WriteLine("=== Semantic Kernel Concurrent Orchestration ==="); -await SKConcurrentOrchestration(); - -Console.WriteLine("\n=== Agent Framework Concurrent Agent Workflow ==="); -await AFConcurrentAgentWorkflow(); - -# region SKConcurrentOrchestration -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -async Task SKConcurrentOrchestration() -{ - ConcurrentOrchestration orchestration = new([ - GetSKTranslationAgent("French"), - GetSKTranslationAgent("Spanish")]) - { - StreamingResponseCallback = StreamingResultCallback, - }; - - InProcessRuntime runtime = new(); - await runtime.StartAsync(); - - // Run the orchestration - OrchestrationResult result = await orchestration.InvokeAsync("Hello, world!", runtime); - string[] texts = await result.GetValueAsync(TimeSpan.FromSeconds(20)); - - await runtime.RunUntilIdleAsync(); -} -#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -ChatCompletionAgent GetSKTranslationAgent(string targetLanguage) -{ - var kernel = Kernel.CreateBuilder().AddAzureOpenAIChatCompletion(deploymentName, endpoint, new AzureCliCredential()).Build(); - return new ChatCompletionAgent() - { - Kernel = kernel, - Instructions = string.Format(agentInstructions, targetLanguage), - Description = $"Agent that translates texts to {targetLanguage}", - Name = $"SKTranslationAgent_{targetLanguage}" - }; -} - -ValueTask StreamingResultCallback(StreamingChatMessageContent streamedResponse, bool isFinal) -{ - Console.Write(streamedResponse.Content); - - if (isFinal) - { - Console.WriteLine(); - } - - return ValueTask.CompletedTask; -} -# endregion - -# region AFConcurrentAgentWorkflow -async Task AFConcurrentAgentWorkflow() -{ - var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); - var frenchAgent = GetAFTranslationAgent("French", client); - var spanishAgent = GetAFTranslationAgent("Spanish", client); - var concurrentAgentWorkflow = AgentWorkflowBuilder.BuildConcurrent([frenchAgent, spanishAgent]); - - await using StreamingRun run = await InProcessExecution.StreamAsync(concurrentAgentWorkflow, "Hello, world!"); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - - string? lastExecutorId = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) - { - if (evt is AgentRunUpdateEvent e) - { - if (string.IsNullOrEmpty(e.Update.Text)) - { - continue; - } - - if (e.ExecutorId != lastExecutorId) - { - lastExecutorId = e.ExecutorId; - Console.WriteLine(); - Console.Write($"{e.Update.AuthorName}: "); - } - - Console.Write(e.Update.Text); - } - } -} - -ChatClientAgent GetAFTranslationAgent(string targetLanguage, IChatClient chatClient) => - new(chatClient, string.Format(agentInstructions, targetLanguage), name: $"AFTranslationAgent_{targetLanguage}"); -# endregion diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj deleted file mode 100644 index a548ac03d00..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/AgentOrchestrations_Step02_Sequential.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net9.0 - enable - enable - $(NoWarn);CA1812;RCS1102;CA1707;VSTHRD200 - true - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/Program.cs b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/Program.cs deleted file mode 100644 index e317ac0ff87..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step02_Sequential/Program.cs +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.Orchestration; -using Microsoft.SemanticKernel.Agents.Orchestration.Sequential; -using Microsoft.SemanticKernel.Agents.Runtime.InProcess; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -var agentInstructions = "You are a translation assistant who only responds in {0}. Respond to any input by outputting the name of the input language and then translating the input to {0}."; - -// This sample compares running sequential orchestrations using -// Semantic Kernel and the Agent Framework. -Console.WriteLine("=== Semantic Kernel Sequential Orchestration ==="); -await SKSequentialOrchestration(); - -Console.WriteLine("\n=== Agent Framework Sequential Agent Workflow ==="); -await AFSequentialAgentWorkflow(); - -# region SKSequentialOrchestration -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -async Task SKSequentialOrchestration() -{ - SequentialOrchestration orchestration = new([ - GetSKTranslationAgent("French"), - GetSKTranslationAgent("Spanish"), - GetSKTranslationAgent("English")]) - { - StreamingResponseCallback = StreamingResultCallback, - }; - - InProcessRuntime runtime = new(); - await runtime.StartAsync(); - - // Run the orchestration - OrchestrationResult result = await orchestration.InvokeAsync("Hello, world!", runtime); - string text = await result.GetValueAsync(TimeSpan.FromSeconds(20)); - - await runtime.RunUntilIdleAsync(); -} -#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -ChatCompletionAgent GetSKTranslationAgent(string targetLanguage) -{ - var kernel = Kernel.CreateBuilder().AddAzureOpenAIChatCompletion(deploymentName, endpoint, new AzureCliCredential()).Build(); - return new ChatCompletionAgent() - { - Kernel = kernel, - Instructions = string.Format(agentInstructions, targetLanguage), - Description = $"Agent that translates texts to {targetLanguage}", - Name = $"SKTranslationAgent_{targetLanguage}" - }; -} - -ValueTask StreamingResultCallback(StreamingChatMessageContent streamedResponse, bool isFinal) -{ - Console.Write(streamedResponse.Content); - - if (isFinal) - { - Console.WriteLine(); - } - - return ValueTask.CompletedTask; -} -# endregion - -# region AFSequentialAgentWorkflow -async Task AFSequentialAgentWorkflow() -{ - var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); - var frenchAgent = GetAFTranslationAgent("French", client); - var spanishAgent = GetAFTranslationAgent("Spanish", client); - var englishAgent = GetAFTranslationAgent("English", client); - var sequentialAgentWorkflow = AgentWorkflowBuilder.BuildSequential( - [frenchAgent, spanishAgent, englishAgent]); - - await using StreamingRun run = await InProcessExecution.StreamAsync(sequentialAgentWorkflow, "Hello, world!"); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - - string? lastExecutorId = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) - { - if (evt is AgentRunUpdateEvent e) - { - if (string.IsNullOrEmpty(e.Update.Text)) - { - continue; - } - - if (e.ExecutorId != lastExecutorId) - { - lastExecutorId = e.ExecutorId; - Console.WriteLine(); - Console.Write($"{e.Update.AuthorName}: "); - } - - Console.Write(e.Update.Text); - } - } -} - -ChatClientAgent GetAFTranslationAgent(string targetLanguage, IChatClient chatClient) => - new(chatClient, string.Format(agentInstructions, targetLanguage), name: $"AFTranslationAgent_{targetLanguage}"); -# endregion diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj deleted file mode 100644 index a548ac03d00..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/AgentOrchestrations_Step03_Handoff.csproj +++ /dev/null @@ -1,26 +0,0 @@ - - - - Exe - net9.0 - enable - enable - $(NoWarn);CA1812;RCS1102;CA1707;VSTHRD200 - true - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/Program.cs b/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/Program.cs deleted file mode 100644 index f66fefe535b..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AgentOrchestrations/Step03_Handoff/Program.cs +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using System.Text.Json; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.Orchestration; -using Microsoft.SemanticKernel.Agents.Orchestration.Handoff; -using Microsoft.SemanticKernel.Agents.Runtime.InProcess; -using Microsoft.SemanticKernel.ChatCompletion; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - -// Queries to simulate user input during the interactive orchestration -List Queries = [ - "I'd like to track the status of my first order 123.", - "I want to return another order of mine whose ID is 456 because it arrived damaged.", -]; - -// This sample compares running handoff orchestrations using -// Semantic Kernel and the Agent Framework. -Console.WriteLine("=== Semantic Kernel Handoff Orchestration ==="); -// State to help format the streaming output -bool newAgentTurn = true; -string previousFunctionCallId = string.Empty; -await SKHandoffOrchestration(); - -Console.WriteLine("\n=== Agent Framework Handoff Agent Workflow ==="); -await AFHandoffAgentWorkflow(); - -# region SKHandoffOrchestration -[KernelFunction] -string SKCheckOrderStatus(string orderId) => $"Order {orderId} is shipped and will arrive in 2-3 days."; - -[KernelFunction] -string SKProcessReturn(string orderId, string reason) => $"Return for order {orderId} has been processed successfully."; - -[KernelFunction] -string SKProcessRefund(string orderId, string reason) => $"Refund for order {orderId} has been processed successfully."; - -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. -async Task SKHandoffOrchestration() -{ - // Create agents - var triageAgent = GetSKAgent( - instructions: "You are a customer support agent that triages issues.", - name: "TriageAgent", - description: "Handle customer requests."); - var statusAgent = GetSKAgent( - instructions: "You are a customer support agent that checks order status.", - name: "OrderStatusAgent", - description: "Handle order status requests."); - statusAgent.Kernel.Plugins.AddFromFunctions("OrderStatusPlugin", [KernelFunctionFactory.CreateFromMethod(SKCheckOrderStatus)]); - var returnAgent = GetSKAgent( - instructions: "You are a customer support agent that handles order returns.", - name: "OrderReturnAgent", - description: "Handle order return requests."); - returnAgent.Kernel.Plugins.AddFromFunctions("OrderReturnPlugin", [KernelFunctionFactory.CreateFromMethod(SKProcessReturn)]); - var refundAgent = GetSKAgent( - instructions: "You are a customer support agent that handles order refunds.", - name: "OrderRefundAgent", - description: "Handle order refund requests."); - refundAgent.Kernel.Plugins.AddFromFunctions("OrderRefundPlugin", [KernelFunctionFactory.CreateFromMethod(SKProcessRefund)]); - - Queue queries = new(Queries); - - // Create orchestration with handoffs - HandoffOrchestration orchestration = - new(OrchestrationHandoffs - .StartWith(triageAgent) - .Add(triageAgent, statusAgent, returnAgent, refundAgent) - .Add(statusAgent, triageAgent, "Transfer to this agent if the issue is not status related") - .Add(returnAgent, triageAgent, "Transfer to this agent if the issue is not return related") - .Add(refundAgent, triageAgent, "Transfer to this agent if the issue is not refund related"), - triageAgent, - statusAgent, - returnAgent, - refundAgent) - { - InteractiveCallback = () => - { - string input = queries.Count > 0 ? queries.Dequeue() : "exit"; - Console.WriteLine($"\nUser: {input}"); - return ValueTask.FromResult(new ChatMessageContent(AuthorRole.User, input)); - }, - StreamingResponseCallback = StreamingResultCallback, - }; - - InProcessRuntime runtime = new(); - await runtime.StartAsync(); - - // Run the orchestration - OrchestrationResult result = await orchestration.InvokeAsync( - "I am a customer that needs help with my two orders", - runtime); - string text = await result.GetValueAsync(); - Console.WriteLine($"\nFinal Result: {text}"); - - await runtime.RunUntilIdleAsync(); -} -#pragma warning restore SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -ChatCompletionAgent GetSKAgent(string instructions, string name, string description) -{ - var kernel = Kernel.CreateBuilder().AddAzureOpenAIChatCompletion(deploymentName, endpoint, new AzureCliCredential()).Build(); - return new ChatCompletionAgent() - { - Kernel = kernel, - Instructions = instructions, - Description = description, - Name = name - }; -} - -ValueTask StreamingResultCallback(StreamingChatMessageContent streamedResponse, bool isFinal) -{ - if (newAgentTurn) - { - Console.Write($"\n{streamedResponse.AuthorName}: "); - newAgentTurn = false; - } - Console.Write(streamedResponse.Content); - - if (streamedResponse.Items.OfType().FirstOrDefault() - is StreamingFunctionCallUpdateContent call) - { - if (call.CallId is not null && previousFunctionCallId != call.CallId) - { - Console.Write($"\nCalling function '{call.Name}' with arguments: "); - previousFunctionCallId = call.CallId; - } - if (!string.IsNullOrEmpty(call.Arguments)) - { - Console.Write($"{call.Arguments}"); - } - } - - if (isFinal) - { - newAgentTurn = true; - previousFunctionCallId = string.Empty; - Console.WriteLine(); - } - - return ValueTask.CompletedTask; -} -# endregion - -# region AFHandoffAgentWorkflow -[Description("Get the order status for a given order ID.")] -static string AFCheckOrderStatus([Description("The order ID to check the status for.")] string orderId) - => $"Order {orderId} is shipped and will arrive in 2-3 days."; - -[Description("Process a return for a given order ID.")] -static string AFProcessReturn( - [Description("The order ID to process the return for.")] string orderId, - [Description("The reason for the return.")] string reason) - => $"Return for order {orderId} has been processed successfully for the following reason: {reason}."; - -[Description("Process a refund for a given order ID.")] -static string AFProcessRefund([Description("The order ID to process the refund for.")] string orderId) - => $"Refund for order {orderId} has been processed successfully."; - -async Task AFHandoffAgentWorkflow() -{ - // Create agents - var client = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient(); - - ChatClientAgent triageAgent = new(client, - instructions: "A customer support agent that triages issues.", - name: "TriageAgent", - description: "Handle customer requests."); - ChatClientAgent statusAgent = new(client, - name: "OrderStatusAgent", - instructions: "Handle order status requests.", - description: "A customer support agent that checks order status.", - tools: [AIFunctionFactory.Create(AFCheckOrderStatus)]); - ChatClientAgent returnAgent = new(client, - name: "OrderReturnAgent", - instructions: "Handle order return requests.", - description: "A customer support agent that handles order returns.", - tools: [AIFunctionFactory.Create(AFProcessReturn)]); - ChatClientAgent refundAgent = new(client, - name: "OrderRefundAgent", - instructions: "Handle order refund requests.", - description: "A customer support agent that handles order refund.", - tools: [AIFunctionFactory.Create(AFProcessRefund)]); - - // Create workflow with handoffs - var handoffAgentWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent) - .WithHandoffs(triageAgent, [statusAgent, returnAgent, refundAgent]) - .WithHandoff(statusAgent, triageAgent, "Transfer to this agent if the issue is not status related") - .WithHandoff(returnAgent, triageAgent, "Transfer to this agent if the issue is not return related") - .WithHandoff(refundAgent, triageAgent, "Transfer to this agent if the issue is not refund related") - .Build(); - - // Run the workflow - List messages = []; - foreach (var query in Queries) - { - Console.WriteLine($"User: {query}"); - messages.Add(new(ChatRole.User, query)); - - await using var run = await InProcessExecution.StreamAsync(handoffAgentWorkflow, messages); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - - string? lastExecutorId = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) - { - if (evt is AgentRunUpdateEvent e) - { - if (string.IsNullOrEmpty(e.Update.Text) && e.Update.Contents.Count == 0) - { - continue; - } - - if (e.ExecutorId != lastExecutorId) - { - lastExecutorId = e.ExecutorId; - Console.WriteLine(); - Console.Write($"{e.Update.AuthorName}: "); - } - - Console.Write(e.Update.Text); - - if (e.Update.Contents.OfType().FirstOrDefault() - is Microsoft.Extensions.AI.FunctionCallContent call) - { - Console.WriteLine(); - Console.WriteLine($"Calling function '{call.Name}' with arguments: {JsonSerializer.Serialize(call.Arguments)}"); - } - } - else if (evt is WorkflowOutputEvent output) - { - Console.WriteLine("\n"); - messages.AddRange(output.As>()!); - } - } - } -} -# endregion diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj deleted file mode 100644 index a0295bca1d6..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/AzureAIFoundry_Step01_Basics.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - Exe - net9.0 - enable - enable - $(NoWarn);CA1812;RCS1102 - true - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs deleted file mode 100644 index cc2769be3d5..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step01_Basics/Program.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.AzureAI; - -var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var azureAgentClient = AzureAIAgent.CreateAgentsClient(azureEndpoint, new AzureCliCredential()); - - PersistentAgent definition = await azureAgentClient.Administration.CreateAgentAsync( - deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); - - AzureAIAgent agent = new(definition, azureAgentClient); - - var thread = new AzureAIAgentThread(azureAgentClient); - - AzureAIAgentInvokeOptions options = new() { MaxPromptTokens = 1000 }; - var result = await agent.InvokeAsync(userInput, thread, options).FirstAsync(); - Console.WriteLine(result.Message); - - Console.WriteLine("---"); - await foreach (StreamingChatMessageContent update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update); - } - - // Clean up - await thread.DeleteAsync(); - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); - - var agent = await azureAgentClient.CreateAIAgentAsync( - deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj deleted file mode 100644 index 6c13bf9446d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/AzureAIFoundry_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs deleted file mode 100644 index c8a5ba8daba..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.AzureAI; - -var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -Console.WriteLine($"User Input: {userInput}"); - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var azureAgentClient = AzureAIAgent.CreateAgentsClient(azureEndpoint, new AzureCliCredential()); - - PersistentAgent definition = await azureAgentClient.Administration.CreateAgentAsync(deploymentName, instructions: "You are a helpful assistant"); - - AzureAIAgent agent = new(definition, azureAgentClient) - { - Kernel = Kernel.CreateBuilder().Build(), - Name = "Host", - Instructions = "You are a helpful assistant", - Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), - }; - - var thread = new AzureAIAgentThread(azureAgentClient); - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); - - Console.WriteLine("---"); - await foreach (ChatMessageContent update in agent.InvokeAsync(userInput, thread)) - { - Console.Write(update); - } - - // Clean up - await thread.DeleteAsync(); - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); - - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, instructions: "Answer questions about the menu"); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { Tools = [AIFunctionFactory.Create(GetWeather)] }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj deleted file mode 100644 index 6c13bf9446d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/AzureAIFoundry_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 3930603e1fa..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.AzureAI; - -var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => AzureAIAgent.CreateAgentsClient(azureEndpoint, new AzureCliCredential())); - serviceCollection.AddTransient((sp) => - { - var azureAgentClient = sp.GetRequiredService(); - - Console.Write("Creating agent in the cloud..."); - - PersistentAgent definition = azureAgentClient.Administration - .CreateAgent(deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); - - Console.Write("Done\n"); - - return new(definition, azureAgentClient); - }); - serviceCollection.AddKernel(); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = new AzureAIAgentThread(agent.Client); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); - - Console.WriteLine("---"); - await foreach (ChatMessageContent update in agent.InvokeAsync(userInput, thread)) - { - Console.Write(update); - } - - // Clean up - await thread.DeleteAsync(); - await agent.Client.Administration.DeleteAgentAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new PersistentAgentsClient(azureEndpoint, new AzureCliCredential())); - serviceCollection.AddTransient((sp) => - { - var azureAgentClient = sp.GetRequiredService(); - - return azureAgentClient.CreateAIAgent( - deploymentName, - name: "GenerateStory", - instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread)) - { - Console.Write(update); - } - - // Clean up - var azureAgentClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj deleted file mode 100644 index e9bdf16aa86..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/AzureAIFoundry_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index 0dc994a9c35..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureAIFoundry/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.AzureAI; - -var azureEndpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var azureAgentClient = AzureAIAgent.CreateAgentsClient(azureEndpoint, new AzureCliCredential()); - - PersistentAgent definition = await azureAgentClient.Administration.CreateAgentAsync(deploymentName, tools: [new CodeInterpreterToolDefinition()]); - - AzureAIAgent agent = new(definition, azureAgentClient); - var thread = new AzureAIAgentThread(azureAgentClient); - - // SK Azure AI Agent provides the code interpreter content and the assistant message as different contents in the call iteration. - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(AzureAIAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var azureAgentClient = new PersistentAgentsClient(azureEndpoint, new AzureCliCredential()); - var agent = await azureAgentClient.CreateAIAgentAsync(deploymentName, tools: [new CodeInterpreterToolDefinition()]); - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - // To capture the code interpreter input we need to break glass all the updates raw representations, to check for the RunStepDetailsUpdate type and - // get the CodeInterpreterInput property which contains the generated code. - // Note: Similar logic would needed for each individual update if used in the agent.RunStreamingAsync streaming API to aggregate or yield the generated code. - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Update the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await azureAgentClient.Threads.DeleteThreadAsync(chatThread.ConversationId); - } - await azureAgentClient.Administration.DeleteAgentAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/AzureOpenAI_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/AzureOpenAI_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/AzureOpenAI_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/Program.cs deleted file mode 100644 index 527f813be67..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step01_Basics/Program.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var agent = new ChatCompletionAgent() - { - Kernel = builder.Build(), - Name = "Joker", - Instructions = "You are good at telling jokes.", - }; - - var thread = new ChatHistoryAgentThread(); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new AgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/AzureOpenAI_Step02_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/AzureOpenAI_Step02_ToolCall.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/AzureOpenAI_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/Program.cs deleted file mode 100644 index 56ca87973a2..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -Console.WriteLine($"User Input: {userInput}"); - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - var builder = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - ChatCompletionAgent agent = new() - { - Instructions = "You are a helpful assistant", - Kernel = builder.Build(), - Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - Console.WriteLine("\n=== SK Agent Response ===\n"); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgent() -{ - var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); - - Console.WriteLine("\n=== AF Agent Response ===\n"); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/AzureOpenAI_Step03_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/AzureOpenAI_Step03_DependencyInjection.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/AzureOpenAI_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs deleted file mode 100644 index caf166674d0..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAI/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => new ChatCompletionAgent() - { - Kernel = sp.GetRequiredService(), - Name = "Joker", - Instructions = "You are good at telling jokes." - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new(endpoint), new AzureCliCredential()) - .GetChatClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/AzureOpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index beb7bed1c96..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/AzureOpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index 4ec04a276a1..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - - var agent = await assistantClient.CreateAIAgentAsync(deploymentName, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/AzureOpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index ad6b00be1ca..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddKernel().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(deploymentName, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient()); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(deploymentName, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/AzureOpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index 5353aaab5ff..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetAssistantClient(); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgent(); -await AFAgent(); - -async Task SKAgent() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - var _ = Kernel.CreateBuilder().AddAzureOpenAIChatClient(deploymentName, endpoint, new AzureCliCredential()); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(deploymentName, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgent() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(deploymentName, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/AzureOpenAIResponses_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/AzureOpenAIResponses_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/AzureOpenAIResponses_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/Program.cs deleted file mode 100644 index 22a17887e12..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step01_Basics/Program.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var responseClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName); - OpenAIResponseAgent agent = new(responseClient) - { - Name = "Joker", - Instructions = "You are good at telling jokes.", - StoreEnabled = true - }; - - var agentOptions = new OpenAIResponseAgentInvokeOptions() { ResponseCreationOptions = new() { MaxOutputTokenCount = 1000 } }; - - Microsoft.SemanticKernel.Agents.AgentThread? thread = null; - await foreach (var item in agent.InvokeAsync(userInput, thread, agentOptions)) - { - thread = item.Thread; - Console.WriteLine(item.Message); - } - - Console.WriteLine("---"); - await foreach (var item in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - thread = item.Thread; - Console.Write(item.Message); - } -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/AzureOpenAIResponses_Step02_ReasoningModel.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/AzureOpenAIResponses_Step02_ReasoningModel.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/AzureOpenAIResponses_Step02_ReasoningModel.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs deleted file mode 100644 index 8ee5ae89b6e..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step02_ReasoningModel/Program.cs +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.ChatCompletion; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "o4-mini"; -var userInput = - """ - Instructions: - - Given the React component below, think about it and change it so that nonfiction books have red - text. - - Return only the code in your reply - - Do not include any additional formatting, such as markdown code blocks - - For formatting, use four space tabs, and do not allow any lines of code to - exceed 80 columns - const books = [ - { title: 'Dune', category: 'fiction', id: 1 }, - { title: 'Frankenstein', category: 'fiction', id: 2 }, - { title: 'Moneyball', category: 'nonfiction', id: 3 }, - ]; - export default function BookList() { - const listItems = books.map(book => -
  • - {book.title} -
  • - ); - return ( -
      {listItems}
    - ); - } - """; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var responseClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName); - OpenAIResponseAgent agent = new(responseClient) - { - Name = "Thinker", - Instructions = "You are good at thinking hard before answering.", - StoreEnabled = true - }; - - var agentOptions = new OpenAIResponseAgentInvokeOptions() - { - ResponseCreationOptions = new() - { - MaxOutputTokenCount = 8000, - ReasoningOptions = new() - { - ReasoningEffortLevel = OpenAI.Responses.ResponseReasoningEffortLevel.High, - ReasoningSummaryVerbosity = OpenAI.Responses.ResponseReasoningSummaryVerbosity.Detailed - } - } - }; - - Microsoft.SemanticKernel.Agents.AgentThread? thread = null; - await foreach (var item in agent.InvokeAsync(userInput, thread, agentOptions)) - { - thread = item.Thread; - foreach (var content in item.Message.Items) - { - if (content is ReasoningContent thinking) - { - Console.Write($"Thinking: \n{thinking}\n---\n"); - } - else if (content is Microsoft.SemanticKernel.TextContent text) - { - Console.Write($"Assistant: {text}"); - } - } - Console.WriteLine(item.Message); - } - - Console.WriteLine("---"); - var userMessage = new ChatMessageContent(AuthorRole.User, userInput); - await foreach (var item in agent.InvokeStreamingAsync(userMessage, thread, agentOptions)) - { - thread = item.Thread; - foreach (var content in item.Message.Items) - { - // Currently SK Agent doesn't output thinking in streaming mode. - // SK Issue: https://github.com/microsoft/semantic-kernel/issues/13046 - // OpenAI SDK Issue: https://github.com/openai/openai-dotnet/issues/643 - if (content is StreamingReasoningContent thinking) - { - Console.WriteLine($"Thinking: [{thinking}]"); - continue; - } - - if (content is StreamingTextContent text) - { - Console.WriteLine($"Response: [{text}]"); - } - } - } -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName) - .CreateAIAgent(name: "Thinker", instructions: "You are good at thinking hard before answering."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() - { - MaxOutputTokens = 8000, - // Microsoft.Extensions.AI currently does not have an abstraction for reasoning-effort, - // we need to break glass using the RawRepresentationFactory. - RawRepresentationFactory = (_) => new OpenAI.Responses.ResponseCreationOptions() - { - ReasoningOptions = new() - { - ReasoningEffortLevel = OpenAI.Responses.ResponseReasoningEffortLevel.High, - ReasoningSummaryVerbosity = OpenAI.Responses.ResponseReasoningSummaryVerbosity.Detailed - } - } - }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - - // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. - string assistantThinking = string.Join("\n", result.Messages - .SelectMany(m => m.Contents) - .OfType() - .Select(trc => trc.Text)); - - var assistantText = result.Text; - Console.WriteLine($"Thinking: \n{assistantThinking}\n---\n"); - Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - var thinkingContents = update.Contents - .OfType() - .Select(trc => trc.Text) - .ToList(); - - if (thinkingContents.Count != 0) - { - Console.WriteLine($"Thinking: [{string.Join("\n", thinkingContents)}]"); - continue; - } - - Console.WriteLine($"Response: [{update.Text}]"); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/AzureOpenAIResponses_Step03_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/AzureOpenAIResponses_Step03_ToolCall.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/AzureOpenAIResponses_Step03_ToolCall.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs deleted file mode 100644 index 12bb3b46b7f..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step03_ToolCall/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -Console.WriteLine($"User Input: {userInput}"); - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - OpenAIResponseAgent agent = new(new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName)); - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - Console.WriteLine("\n=== SK Agent Response ===\n"); - - await foreach (ChatMessageContent responseItem in agent.InvokeAsync(userInput)) - { - if (!string.IsNullOrWhiteSpace(responseItem.Content)) - { - Console.WriteLine(responseItem); - } - } -} - -async Task AFAgentAsync() -{ - var agent = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName) - .CreateAIAgent(instructions: "You are a helpful assistant", tools: [AIFunctionFactory.Create(GetWeather)]); - - Console.WriteLine("\n=== AF Agent Response ===\n"); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/AzureOpenAIResponses_Step04_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/AzureOpenAIResponses_Step04_DependencyInjection.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/AzureOpenAIResponses_Step04_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs deleted file mode 100644 index e2bef86a7fe..00000000000 --- a/dotnet/samples/SemanticKernelMigration/AzureOpenAIResponses/Step04_DependencyInjection/Program.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) - => new OpenAIResponseAgent(new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName)) - { - Name = "Joker", - Instructions = "You are good at telling jokes." - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) => new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()) - .GetOpenAIResponseClient(deploymentName) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/OpenAI_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/OpenAI_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/OpenAI_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/Program.cs deleted file mode 100644 index 05242defcef..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step01_Basics/Program.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder().AddOpenAIChatClient(model, apiKey); - - var agent = new ChatCompletionAgent() - { - Kernel = builder.Build(), - Name = "Joker", - Instructions = "You are good at telling jokes.", - }; - - var thread = new ChatHistoryAgentThread(); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new AgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new OpenAIClient(apiKey).GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/OpenAI_Step02_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/OpenAI_Step02_ToolCall.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/OpenAI_Step02_ToolCall.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/Program.cs deleted file mode 100644 index c3535186596..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -Console.WriteLine($"User Input: {userInput}"); - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - var builder = Kernel.CreateBuilder().AddOpenAIChatClient(model, apiKey); - - ChatCompletionAgent agent = new() - { - Instructions = "You are a helpful assistant", - Kernel = builder.Build(), - Arguments = new KernelArguments(new PromptExecutionSettings() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - Console.WriteLine("\n=== SK Agent Response ===\n"); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgentAsync() -{ - var agent = new OpenAIClient(apiKey).GetChatClient(model).CreateAIAgent( - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - Console.WriteLine("\n=== AF Agent Response ===\n"); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/OpenAI_Step03_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/OpenAI_Step03_DependencyInjection.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/OpenAI_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 0ffdc759029..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAI/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => new ChatCompletionAgent() - { - Kernel = sp.GetRequiredService(), - Name = "Joker", - Instructions = "You are good at telling jokes." - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) - .GetChatClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/OpenAIAssistants_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs deleted file mode 100644 index 933e30bdcec..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step01_Basics/Program.cs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var assistantsClient = new AssistantClient(apiKey); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/OpenAIAssistants_Step02_ToolCall.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs deleted file mode 100644 index 4daf7ee59da..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step02_ToolCall/Program.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var builder = Kernel.CreateBuilder(); - var assistantsClient = new AssistantClient(apiKey); - - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, - instructions: "You are a helpful assistant"); - - OpenAIAssistantAgent agent = new(assistant, assistantsClient) - { - Kernel = builder.Build(), - Arguments = new KernelArguments(new OpenAIPromptExecutionSettings() - { - MaxTokens = 1000, - FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() - }), - }; - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - await foreach (var result in agent.InvokeAsync(userInput, thread)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var assistantClient = new AssistantClient(apiKey); - - var agent = await assistantClient.CreateAIAgentAsync(model, - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/OpenAIAssistants_Step03_DependencyInjection.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs deleted file mode 100644 index 29fefbc0ad7..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step03_DependencyInjection/Program.cs +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.Connectors.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddKernel().AddOpenAIChatClient(model, apiKey); - serviceCollection.AddTransient((sp) => - { - var assistantsClient = sp.GetRequiredService(); - - Assistant assistant = assistantsClient.CreateAssistant(model, new() { Name = "Joker", Instructions = "You are good at telling jokes." }); - - return new OpenAIAssistantAgent(assistant, assistantsClient); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - // Create a thread for the agent conversation. - var assistantsClient = serviceProvider.GetRequiredService(); - var thread = new OpenAIAssistantAgentThread(assistantsClient); - var settings = new OpenAIPromptExecutionSettings() { MaxTokens = 1000 }; - var agentOptions = new OpenAIAssistantAgentInvokeOptions() { KernelArguments = new(settings) }; - - await foreach (var result in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(result.Message); - } - - Console.WriteLine("---"); - await foreach (var update in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update.Message); - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddSingleton((sp) => new AssistantClient(apiKey)); - serviceCollection.AddTransient((sp) => - { - var assistantClient = sp.GetRequiredService(); - - return assistantClient.CreateAIAgent(model, name: "Joker", instructions: "You are good at telling jokes."); - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 1000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } - - // Clean up - var assistantClient = serviceProvider.GetRequiredService(); - if (thread is ChatClientAgentThread chatThread) - { - await assistantClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/OpenAIAssistants_Step04_CodeInterpreter.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs deleted file mode 100644 index e89ac5e9fd0..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIAssistants/Step04_CodeInterpreter/Program.cs +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; -using OpenAI.Assistants; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Create a python code file using the code interpreter tool with a code ready to determine the values in the Fibonacci sequence that are less then the value of 101"; - -var assistantsClient = new AssistantClient(apiKey); - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - // Define the assistant - Assistant assistant = await assistantsClient.CreateAssistantAsync(model, enableCodeInterpreter: true); - - // Create the agent - OpenAIAssistantAgent agent = new(assistant, assistantsClient); - - // Create a thread for the agent conversation. - var thread = new OpenAIAssistantAgentThread(assistantsClient); - - // Respond to user input - await foreach (var content in agent.InvokeAsync(userInput, thread)) - { - if (!string.IsNullOrWhiteSpace(content.Message.Content)) - { - bool isCode = content.Message.Metadata?.ContainsKey(OpenAIAssistantAgent.CodeInterpreterMetadataKey) ?? false; - Console.WriteLine($"\n# {content.Message.Role}{(isCode ? "\n# Generated Code:\n" : ":")}{content.Message.Content}"); - } - - // Check for the citations - foreach (var item in content.Message.Items) - { - // Process each item in the message - if (item is AnnotationContent annotation) - { - if (annotation.Kind != AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - } - - // Clean up - await thread.DeleteAsync(); - await assistantsClient.DeleteAssistantAsync(agent.Id); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = await assistantsClient.CreateAIAgentAsync(model, tools: [new HostedCodeInterpreterTool()]); - - var thread = agent.GetNewThread(); - - var result = await agent.RunAsync(userInput, thread); - Console.WriteLine(result); - - // Extracts via breaking glass the code generated by code interpreter tool - var chatResponse = result.RawRepresentation as ChatResponse; - StringBuilder generatedCode = new(); - foreach (object? updateRawRepresentation in chatResponse?.RawRepresentation as IEnumerable ?? []) - { - if (updateRawRepresentation is RunStepDetailsUpdate update && update.CodeInterpreterInput is not null) - { - generatedCode.Append(update.CodeInterpreterInput); - } - } - - if (!string.IsNullOrEmpty(generatedCode.ToString())) - { - Console.WriteLine($"\n# {chatResponse?.Messages[0].Role}:Generated Code:\n{generatedCode}"); - } - - // Check for the citations - foreach (var textContent in result.Messages[0].Contents.OfType()) - { - foreach (var annotation in textContent.Annotations ?? []) - { - if (annotation is CitationAnnotation citation) - { - if (citation.Url is null) - { - Console.WriteLine($" [{citation.GetType().Name}] {citation.Snippet}: File #{citation.FileId}"); - } - - foreach (var region in citation.AnnotatedRegions ?? []) - { - if (region is TextSpanAnnotatedRegion textSpanRegion) - { - Console.WriteLine($"\n[TextSpan Region] {textSpanRegion.StartIndex}-{textSpanRegion.EndIndex}"); - } - } - } - } - } - - // Clean up - if (thread is ChatClientAgentThread chatThread) - { - await assistantsClient.DeleteThreadAsync(chatThread.ConversationId); - } - await assistantsClient.DeleteAssistantAsync(agent.Id); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/OpenAIResponses_Step01_Basics.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/OpenAIResponses_Step01_Basics.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/OpenAIResponses_Step01_Basics.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/Program.cs deleted file mode 100644 index 9e7968df1cf..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step01_Basics/Program.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var responseClient = new OpenAIClient(apiKey).GetOpenAIResponseClient(model); - OpenAIResponseAgent agent = new(responseClient) - { - Name = "Joker", - Instructions = "You are good at telling jokes.", - StoreEnabled = true - }; - - var agentOptions = new OpenAIResponseAgentInvokeOptions() { ResponseCreationOptions = new() { MaxOutputTokenCount = 1000 } }; - - Microsoft.SemanticKernel.Agents.AgentThread? thread = null; - await foreach (var item in agent.InvokeAsync(userInput, thread, agentOptions)) - { - Console.WriteLine(item.Message); - thread = item.Thread; - } - - Console.WriteLine("---"); - await foreach (var item in agent.InvokeStreamingAsync(userInput, thread, agentOptions)) - { - // Thread need to be updated for subsequent calls - thread = item.Thread; - Console.Write(item.Message); - } -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new OpenAIClient(apiKey).GetOpenAIResponseClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() { MaxOutputTokens = 8000 }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - Console.WriteLine(result); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - Console.Write(update); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/OpenAIResponses_Step02_ReasoningModel.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/OpenAIResponses_Step02_ReasoningModel.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/OpenAIResponses_Step02_ReasoningModel.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs deleted file mode 100644 index ca93169f39b..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step02_ReasoningModel/Program.cs +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using Microsoft.SemanticKernel.ChatCompletion; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "o4-mini"; -var userInput = - """ - Instructions: - - Given the React component below, think about it and change it so that nonfiction books have red - text. - - Return only the code in your reply - - Do not include any additional formatting, such as markdown code blocks - - For formatting, use four space tabs, and do not allow any lines of code to - exceed 80 columns - const books = [ - { title: 'Dune', category: 'fiction', id: 1 }, - { title: 'Frankenstein', category: 'fiction', id: 2 }, - { title: 'Moneyball', category: 'nonfiction', id: 3 }, - ]; - export default function BookList() { - const listItems = books.map(book => -
  • - {book.title} -
  • - ); - return ( -
      {listItems}
    - ); - } - """; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var responseClient = new OpenAIClient(apiKey).GetOpenAIResponseClient(model); - OpenAIResponseAgent agent = new(responseClient) - { - Name = "Thinker", - Instructions = "You are good at thinking hard before answering.", - StoreEnabled = true - }; - - var agentOptions = new OpenAIResponseAgentInvokeOptions() - { - ResponseCreationOptions = new() - { - MaxOutputTokenCount = 8000, - ReasoningOptions = new() - { - ReasoningEffortLevel = OpenAI.Responses.ResponseReasoningEffortLevel.High, - ReasoningSummaryVerbosity = OpenAI.Responses.ResponseReasoningSummaryVerbosity.Detailed - } - } - }; - - Microsoft.SemanticKernel.Agents.AgentThread? thread = null; - await foreach (var item in agent.InvokeAsync(userInput, thread, agentOptions)) - { - thread = item.Thread; - foreach (var content in item.Message.Items) - { - if (content is ReasoningContent thinking) - { - Console.Write($"Thinking: \n{thinking}\n---\n"); - } - else if (content is Microsoft.SemanticKernel.TextContent text) - { - Console.Write($"Assistant: {text}"); - } - } - Console.WriteLine(item.Message); - } - - Console.WriteLine("---"); - var userMessage = new ChatMessageContent(AuthorRole.User, userInput); - thread = null; - await foreach (var item in agent.InvokeStreamingAsync(userMessage, thread, agentOptions)) - { - thread = item.Thread; - foreach (var content in item.Message.Items) - { - // Currently SK Agent doesn't output thinking in streaming mode. - // SK Issue: https://github.com/microsoft/semantic-kernel/issues/13046 - // OpenAI SDK Issue: https://github.com/openai/openai-dotnet/issues/643 - if (content is StreamingReasoningContent thinking) - { - Console.WriteLine($"Thinking: [{thinking}]"); - continue; - } - - if (content is StreamingTextContent text) - { - Console.WriteLine($"Response: [{text}]"); - } - } - } -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var agent = new OpenAIClient(apiKey).GetOpenAIResponseClient(model) - .CreateAIAgent(name: "Thinker", instructions: "You are at thinking hard before answering."); - - var thread = agent.GetNewThread(); - var agentOptions = new ChatClientAgentRunOptions(new() - { - MaxOutputTokens = 8000, - // Microsoft.Extensions.AI currently does not have an abstraction for reasoning-effort, - // we need to break glass using the RawRepresentationFactory. - RawRepresentationFactory = (_) => new OpenAI.Responses.ResponseCreationOptions() - { - ReasoningOptions = new() - { - ReasoningEffortLevel = OpenAI.Responses.ResponseReasoningEffortLevel.High, - ReasoningSummaryVerbosity = OpenAI.Responses.ResponseReasoningSummaryVerbosity.Detailed - } - } - }); - - var result = await agent.RunAsync(userInput, thread, agentOptions); - - // Retrieve the thinking as a full text block requires flattening multiple TextReasoningContents from multiple messages content lists. - string assistantThinking = string.Join("\n", result.Messages - .SelectMany(m => m.Contents) - .OfType() - .Select(trc => trc.Text)); - - var assistantText = result.Text; - Console.WriteLine($"Thinking: \n{assistantThinking}\n---\n"); - Console.WriteLine($"Assistant: \n{assistantText}\n---\n"); - - Console.WriteLine("---"); - await foreach (var update in agent.RunStreamingAsync(userInput, thread, agentOptions)) - { - var thinkingContents = update.Contents - .OfType() - .Select(trc => trc.Text) - .ToList(); - - if (thinkingContents.Count != 0) - { - Console.WriteLine($"Thinking: [{string.Join("\n", thinkingContents)}]"); - continue; - } - - Console.WriteLine($"Response: [{update.Text}]"); - } -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/OpenAIResponses_Step03_ToolCall.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/OpenAIResponses_Step03_ToolCall.csproj deleted file mode 100644 index a91ff32320d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/OpenAIResponses_Step03_ToolCall.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/Program.cs deleted file mode 100644 index 45807993b20..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step03_ToolCall/Program.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.ComponentModel; -using Microsoft.Extensions.AI; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "What is the weather like in Amsterdam?"; - -Console.WriteLine($"User Input: {userInput}"); - -[KernelFunction] -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - var builder = Kernel.CreateBuilder().AddOpenAIChatClient(model, apiKey); - - OpenAIResponseAgent agent = new(new OpenAIClient(apiKey).GetOpenAIResponseClient(model)); - - // Initialize plugin and add to the agent's Kernel (same as direct Kernel usage). - agent.Kernel.Plugins.Add(KernelPluginFactory.CreateFromFunctions("KernelPluginName", [KernelFunctionFactory.CreateFromMethod(GetWeather)])); - - Console.WriteLine("\n=== SK Agent Response ===\n"); - - await foreach (ChatMessageContent responseItem in agent.InvokeAsync(userInput)) - { - if (!string.IsNullOrWhiteSpace(responseItem.Content)) - { - Console.WriteLine(responseItem); - } - } -} - -async Task AFAgentAsync() -{ - var agent = new OpenAIClient(apiKey).GetOpenAIResponseClient(model).CreateAIAgent( - instructions: "You are a helpful assistant", - tools: [AIFunctionFactory.Create(GetWeather)]); - - Console.WriteLine("\n=== AF Agent Response ===\n"); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/OpenAIResponses_Step04_DependencyInjection.csproj b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/OpenAIResponses_Step04_DependencyInjection.csproj deleted file mode 100644 index 9a9c652f30d..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/OpenAIResponses_Step04_DependencyInjection.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs b/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs deleted file mode 100644 index 782f6eff571..00000000000 --- a/dotnet/samples/SemanticKernelMigration/OpenAIResponses/Step04_DependencyInjection/Program.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.SemanticKernel.Agents.OpenAI; -using OpenAI; - -var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); -var model = System.Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o"; -var userInput = "Tell me a joke about a pirate."; - -Console.WriteLine($"User Input: {userInput}"); - -await SKAgentAsync(); -await AFAgentAsync(); - -async Task SKAgentAsync() -{ - Console.WriteLine("\n=== SK Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) - => new OpenAIResponseAgent(new OpenAIClient(apiKey).GetOpenAIResponseClient(model)) - { - Name = "Joker", - Instructions = "You are good at telling jokes." - }); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.InvokeAsync(userInput).FirstAsync(); - Console.WriteLine(result.Message); -} - -async Task AFAgentAsync() -{ - Console.WriteLine("\n=== AF Agent ===\n"); - - var serviceCollection = new ServiceCollection(); - serviceCollection.AddTransient((sp) => new OpenAIClient(apiKey) - .GetOpenAIResponseClient(model) - .CreateAIAgent(name: "Joker", instructions: "You are good at telling jokes.")); - - await using ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); - var agent = serviceProvider.GetRequiredService(); - - var result = await agent.RunAsync(userInput); - Console.WriteLine(result); -} diff --git a/dotnet/samples/SemanticKernelMigration/Playground/README.md b/dotnet/samples/SemanticKernelMigration/Playground/README.md deleted file mode 100644 index dc00d5e238e..00000000000 --- a/dotnet/samples/SemanticKernelMigration/Playground/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Semantic Kernel Migration Playground - -This is a playground folder with different **Semantic Kernel** projects that can be used to test automatic AI migration to the new **Agent Framework (AF)**. - -## Prompting - -Open your IDE Agentic extension and create a new chat providing the following prompt: - -``` -I need to convert code from Semantic Kernel to the Agent Framework. -Please use the migration guide provided in the #SemanticKernelToAgentFramework.md as a reference. - -The current solution is using central package manager, when referencing the projects in the csproj don't provide the versions. - -Check external references provided by the migration guide if needed. - -You don't need to look for the Central Package Management file, just focus on the project file and the code files. - -When you need help or don't know how to proceed, please ask. -``` \ No newline at end of file diff --git a/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/Program.cs b/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/Program.cs deleted file mode 100644 index 38afca2c2bf..00000000000 --- a/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/Program.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -using Azure.AI.Agents.Persistent; -using Azure.Identity; -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -using Microsoft.SemanticKernel.Agents.AzureAI; -using Microsoft.SemanticKernel.ChatCompletion; - -#pragma warning disable SKEXP0110 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - -var client = new PersistentAgentsClient(Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT"), new AzureCliCredential()); - -// Define the agent -PersistentAgent definition = await client.Administration.CreateAgentAsync( - Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME"), - instructions: "You are a coding assistant that always generates code using the code interpreter tool.", - tools: [new CodeInterpreterToolDefinition()]); - -AzureAIAgent agent = new(definition, client); - -// Create a thread for the agent conversation. -AgentThread thread = new AzureAIAgentThread(client); - -try -{ - await InvokeAgentAsync("Create a python file where it determines the values in the Fibonacci sequence that that are less then the value of 101."); -} -finally -{ - await thread.DeleteAsync(); - await client.Administration.DeleteAgentAsync(agent.Id); -} - -async Task InvokeAgentAsync(string input) -{ - ChatMessageContent message = new(AuthorRole.User, input); - WriteAgentChatMessage(message); - - await foreach (ChatMessageContent response in agent.InvokeAsync(message, thread)) - { - WriteAgentChatMessage(response); - } -} - -void WriteAgentChatMessage(ChatMessageContent message) -{ - // Include ChatMessageContent.AuthorName in output, if present. - string authorExpression = message.Role == AuthorRole.User ? string.Empty : FormatAuthor(); - // Include TextContent (via ChatMessageContent.Content), if present. - string contentExpression = string.IsNullOrWhiteSpace(message.Content) ? string.Empty : message.Content; - bool isCode = message.Metadata?.ContainsKey(AzureAIAgent.CodeInterpreterMetadataKey) ?? false; - string codeMarker = isCode ? "\n [CODE]\n" : " "; - Console.WriteLine($"\n# {message.Role}{authorExpression}:{codeMarker}{contentExpression}"); - - // Provide visibility for inner content (that isn't TextContent). - foreach (KernelContent item in message.Items) - { - if (item is AnnotationContent annotation) - { - if (annotation.Kind == AnnotationKind.UrlCitation) - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: {annotation.ReferenceId} - {annotation.Title}"); - } - else - { - Console.WriteLine($" [{item.GetType().Name}] {annotation.Label}: File #{annotation.ReferenceId}"); - } - } - else if (item is ActionContent action) - { - Console.WriteLine($" [{item.GetType().Name}] {action.Text}"); - } - else if (item is ReasoningContent reasoning) - { - Console.WriteLine($" [{item.GetType().Name}] {reasoning.Text ?? "Thinking..."}"); - } - else if (item is FileReferenceContent fileReference) - { - Console.WriteLine($" [{item.GetType().Name}] File #{fileReference.FileId}"); - } - } - - if ((message.Metadata?.TryGetValue("Usage", out object? usage) ?? false) && usage is RunStepCompletionUsage agentUsage) - { - Console.WriteLine($" [Usage] Tokens: {agentUsage.TotalTokens}, Input: {agentUsage.PromptTokens}, Output: {agentUsage.CompletionTokens}"); - } - - string FormatAuthor() => message.AuthorName is not null ? $" - {message.AuthorName ?? " * "}" : string.Empty; -} diff --git a/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/SemanticKernelBasic.csproj b/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/SemanticKernelBasic.csproj deleted file mode 100644 index 57104074f30..00000000000 --- a/dotnet/samples/SemanticKernelMigration/Playground/SemanticKernelBasic/SemanticKernelBasic.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - - - diff --git a/dotnet/samples/SemanticKernelMigration/README.md b/dotnet/samples/SemanticKernelMigration/README.md deleted file mode 100644 index 971b4649739..00000000000 --- a/dotnet/samples/SemanticKernelMigration/README.md +++ /dev/null @@ -1,393 +0,0 @@ -# Semantic Kernel to Agent Framework Migration Guide - -## What's Changed? -- **Namespace Updates**: From `Microsoft.SemanticKernel.Agents` to `Microsoft.Agents.AI` -- **Agent Creation**: Single fluent API calls vs multi-step builder patterns -- **Thread Management**: Built-in thread management vs manual thread creation -- **Tool Registration**: Direct function registration vs plugin wrapper systems -- **Dependency Injection**: Simplified service registration patterns -- **Invocation Patterns**: Streamlined options and result handling - -## Benefits of Migration -- **Simplified API**: Reduced complexity and boilerplate code -- **Better Performance**: Optimized object creation and memory usage -- **Unified Interface**: Consistent patterns across different AI providers -- **Enhanced Developer Experience**: More intuitive and discoverable APIs - -## Key Changes - -### 1. Namespace Updates - -#### Semantic Kernel - -```csharp -using Microsoft.SemanticKernel; -using Microsoft.SemanticKernel.Agents; -``` - -#### Agent Framework - -Agent Framework namespaces are under `Microsoft.Agents.AI`. -Agent Framework uses the core AI message and content types from `Microsoft.Extensions.AI` for communication between components. - -```csharp -using Microsoft.Extensions.AI; -using Microsoft.Agents.AI; -``` - -### 2. Agent Creation Simplification - -#### Semantic Kernel - -Every agent in Semantic Kernel depends on a `Kernel` instance and will have -an empty `Kernel` if not provided. - -```csharp - Kernel kernel = Kernel - .AddOpenAIChatClient(modelId, apiKey) - .Build(); - - ChatCompletionAgent agent = new() { Instructions = ParrotInstructions, Kernel = kernel }; -``` - -Azure AI Foundry requires an agent resource to be created in the cloud before creating a local agent class that uses it. - -```csharp -PersistentAgentsClient azureAgentClient = AzureAIAgent.CreateAgentsClient(azureEndpoint, new AzureCliCredential()); - -PersistentAgent definition = await azureAgentClient.Administration.CreateAgentAsync( - deploymentName, - instructions: ParrotInstructions); - -AzureAIAgent agent = new(definition, azureAgentClient); - ``` - -#### Agent Framework - -Agent creation in Agent Framework is made simpler with extensions provided by all main providers. - -```csharp -AIAgent openAIAgent = chatClient.CreateAIAgent(instructions: ParrotInstructions); -AIAgent azureFoundryAgent = await persistentAgentsClient.CreateAIAgentAsync(instructions: ParrotInstructions); -AIAgent openAIAssistantAgent = await assistantClient.CreateAIAgentAsync(instructions: ParrotInstructions); -``` - -Additionally for hosted agent providers you can also use the `GetAIAgent` to retrieve an agent from an existing hosted agent. - -```csharp -AIAgent azureFoundryAgent = await persistentAgentsClient.GetAIAgentAsync(agentId); -``` - -### 3. Agent Thread Creation - -#### Semantic Kernel - -The caller has to know the thread type and create it manually. - -```csharp -// Create a thread for the agent conversation. -AgentThread thread = new OpenAIAssistantAgentThread(this.AssistantClient); -AgentThread thread = new AzureAIAgentThread(this.Client); -AgentThread thread = new OpenAIResponseAgentThread(this.Client); -``` - -#### Agent Framework - -The agent is responsible for creating the thread. - -```csharp -// New -AgentThread thread = agent.GetNewThread(); -``` - -### 4. Hosted Agent Thread Cleanup - -This case applies exclusively to a few AI providers that still provide hosted threads. - -#### Semantic Kernel - -Threads have a `self` deletion method - -i.e: OpenAI Assistants Provider -```csharp -await thread.DeleteAsync(); -``` - -#### Agent Framework - -> [!NOTE] -> OpenAI Responses introduced a new conversation model that simplifies how conversations are handled. This simplifies hosted thread management compared to the now deprecated OpenAI Assistants model. For more information see the [OpenAI Assistants migration guide](https://platform.openai.com/docs/assistants/migration). - -Agent Framework doesn't have a thread deletion API in the `AgentThread` type as not all providers support hosted threads or thread deletion and this will become more common as more providers shift to responses based architectures. - -If you require thread deletion and the provider allows this, the caller **should** keep track of the created threads and delete them later when necessary via the provider's sdk. - -i.e: OpenAI Assistants Provider -```csharp -await assistantClient.DeleteThreadAsync(thread.ConversationId); -``` - -### 5. Tool Registration - -#### Semantic Kernel - -In semantic kernel to expose a function as a tool you must: - -1. Decorate the function with a `[KernelFunction]` attribute. -2. Have a `Plugin` class or use the `KernelPluginFactory` to wrap the function. -3. Have a `Kernel` to add your plugin to. -4. Pass the `Kernel` to the agent. - -```csharp -KernelFunction function = KernelFunctionFactory.CreateFromMethod(GetWeather); -KernelPlugin plugin = KernelPluginFactory.CreateFromFunctions("KernelPluginName", [function]); -Kernel kernel = ... // Create kernel -kernel.Plugins.Add(plugin); - -ChatCompletionAgent agent = new() { Kernel = kernel, ... }; -``` - -#### Agent Framework - -In agent framework in a single call you can register tools directly in the agent creation process. - -```csharp -AIAgent agent = chatClient.CreateAIAgent(tools: [AIFunctionFactory.Create(GetWeather)]); -``` - -### 6. Agent Non-Streaming Invocation - -Key differences can be seen in the method names from `Invoke` to `Run`, return types and parameters `AgentRunOptions`. - -#### Semantic Kernel - -The Non-Streaming uses a streaming pattern `IAsyncEnumerable>` for returning multiple agent messages. - -```csharp -await foreach (AgentResponseItem result in agent.InvokeAsync(userInput, thread, agentOptions)) -{ - Console.WriteLine(result.Message); -} -``` - -#### Agent Framework - -The Non-Streaming returns a single `AgentRunResponse` with the agent response that can contain multiple messages. -The text result of the run is available in `AgentRunResponse.Text` or `AgentRunResponse.ToString()`. -All messages created as part of the response is returned in the `AgentRunResponse.Messages` list. -This may include tool call messages, function results, reasoning updates and final results. - -```csharp -AgentRunResponse agentResponse = await agent.RunAsync(userInput, thread); -``` - -### 7. Agent Streaming Invocation - -Key differences in the method names from `Invoke` to `Run`, return types and parameters `AgentRunOptions`. - -#### Semantic Kernel - -```csharp -await foreach (StreamingChatMessageContent update in agent.InvokeStreamingAsync(userInput, thread)) -{ - Console.Write(update); -} -``` - -#### Agent Framework - -Similar streaming API pattern with the key difference being that it returns `AgentRunResponseUpdate` objects including more agent related information per update. - -All updates produced by any service underlying the AIAgent is returned. The textual result of the agent is available by concatenating the `AgentRunResponse.Text` values. - -```csharp -await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(userInput, thread)) -{ - Console.Write(update); // Update is ToString() friendly -} -``` - -### 8. Tool Function Signatures - -**Problem**: SK plugin methods need `[KernelFunction]` attributes - -```csharp -public class MenuPlugin -{ - [KernelFunction] // Required for SK - public static MenuItem[] GetMenu() => ...; -} -``` - -**Solution**: AF can use methods directly without attributes - -```csharp -public class MenuTools -{ - [Description("Get menu items")] // Optional description - public static MenuItem[] GetMenu() => ...; -} -``` - -### 9. Options Configuration - -**Problem**: Complex options setup in SK - -```csharp -OpenAIPromptExecutionSettings settings = new() { MaxTokens = 1000 }; -AgentInvokeOptions options = new() { KernelArguments = new(settings) }; -``` - -**Solution**: Simplified options in AF - -```csharp -ChatClientAgentRunOptions options = new(new() { MaxOutputTokens = 1000 }); -``` - -> [!IMPORTANT] -> This example shows passing implementation specific options to a `ChatClientAgent`. Not all `AIAgents` support `ChatClientAgentRunOptions`. -> `ChatClientAgent` is provided to build agents based on underlying inference services, and therefore supports inference options like `MaxOutputTokens`. - -### 10. Dependency Injection - -#### Semantic Kernel - -A `Kernel` registration is required in the service container to be able to create an agent -as every agent abstractions needs to be initialized with a `Kernel` property. - -Semantic Kernel uses the `Agent` type as the base abstraction class for agents. - -```csharp -services.AddKernel().AddProvider(...); -serviceContainer.AddKeyedSingleton( - TutorName, - (sp, key) => - new ChatCompletionAgent() - { - // Passing the kernel is required - Kernel = sp.GetRequiredService(), - }); -``` - -### 11. **Agent Type Consolidation** - -#### Semantic Kernel - -Semantic kernel provides specific agent classes for various services, e.g. - -- `ChatCompletionAgent` for use with chat-completion-based inference services. -- `OpenAIAssistantAgent` for use with the OpenAI Assistants service. -- `AzureAIAgent` for use with the Azure AI Foundry Agents service. - -#### Agent Framework - -The agent framework supports all the abovementioned services via a single agent type, `ChatClientAgent`. - -`ChatClientAgent` can be used to build agents using any underlying service that provides an SDK implementing the `Microsoft.Extensions.AI.IChatClient` interface. - -#### Agent Framework - -The Agent framework provides the `AIAgent` type as the base abstraction class. - -```csharp -services.AddKeyedSingleton(() => client.CreateAIAgent(...)); -``` - -## Migration Samples - -This folder contains **separate console application projects** demonstrating how to transition from **Semantic Kernel (SK)** to the new **Agent Framework (AF)**. - -Each project shows side-by-side comparisons of equivalent functionality in both frameworks and can be run independently. - -Each sample code contains the following: -1. **SK Agent** (Semantic Kernel before) -2. **AF Agent** (Agent Framework after) - -### Running the samples from Visual Studio - -Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. - -You will be prompted for any required environment variables if they are not already set. - -### Prerequisites - -Before you begin, ensure you have the following: - -- [.NET 8.0 SDK or later](https://dotnet.microsoft.com/download) -- For Azure AI Foundry samples: Azure OpenAI service endpoint and deployment configured -- For OpenAI samples: OpenAI API key -- For OpenAI Assistants samples: OpenAI API key with Assistant API access - -### Environment Variables - -Set the appropriate environment variables based on the sample type you want to run: - -**For Azure AI Foundry projects:** -```powershell -$env:AZURE_FOUNDRY_PROJECT_ENDPOINT = "https://-resource.services.ai.azure.com/api/projects/" -``` - -**For OpenAI and OpenAI Assistants projects:** -```powershell -$env:OPENAI_API_KEY = "sk-..." -``` - -**For Azure OpenAI and Azure OpenAI Assistants projects:** -```powershell -$env:AZURE_OPENAI_ENDPOINT = "https://.cognitiveservices.azure.com/" -$env:AZURE_OPENAI_DEPLOYMENT_NAME = "gpt-4o" # Optional, defaults to gpt-4o -``` - -**Optional debug mode:** -```powershell -$env:AF_SHOW_ALL_DEMO_SETTING_VALUES = "Y" -``` - -If environment variables are not set, the demos will prompt you to enter values interactively. - -### Samples - -The migration samples are organized into three categories, each demonstrating different AI service integrations: - -|Category|Description| -|---|---| -|[AzureAIFoundry](./AzureAIFoundry/)|Azure OpenAI service integration samples| -|[AzureOpenAI](./AzureOpenAI/)|Direct Azure OpenAI API integration samples| -|[AzureOpenAIAssistants](./AzureOpenAIAssistants/)|Azure OpenAI Assistants API integration samples| -|[AzureOpenAIResponses](./AzureOpenAIResponses/)|Azure OpenAI Responses API integration samples| -|[OpenAI](./OpenAI/)|Direct OpenAI API integration samples| -|[OpenAIAssistants](./OpenAIAssistants/)|OpenAI Assistants API integration samples| -|[OpenAIResponses](./OpenAIResponses/)|OpenAI Responses API integration samples| - -## Running the samples from the console - -To run any migration sample, navigate to the desired sample directory: - -```powershell -# Azure AI Foundry Examples -cd "AzureAIFoundry\Step01_Basics" -dotnet run - -# Azure OpenAI Examples -cd "AzureOpenAI\Step01_Basics" -dotnet run - -# OpenAI Examples -cd "OpenAI\Step01_Basics" -dotnet run - -# OpenAI Assistants Examples -cd "OpenAIAssistants\Step01_Basics" -dotnet run - -# OpenAI Responses Examples -cd "OpenAIResponses\Step01_Basics" - -# Azure OpenAI Examples -cd "AzureOpenAI\Step01_Basics" -dotnet run - -# Azure OpenAI Assistants Examples -cd "AzureOpenAIAssistants\Step01_Basics" -dotnet run -``` diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs new file mode 100644 index 00000000000..3c59abcef07 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; + +namespace A2A; + +/// +/// Provides extension methods for to simplify the creation of A2A agents. +/// +/// +/// These extensions bridge the gap between A2A SDK client and . +/// +public static class A2AAgentCardExtensions +{ + /// + /// Retrieves an instance of for an existing A2A agent. + /// + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + /// The to use for the agent creation. + /// The to use for HTTP requests. + /// The logger factory for enabling logging within the agent. + /// An instance backed by the A2A agent. + public static async Task GetAIAgentAsync(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) + { + // Create the A2A client using the agent URL from the card. + var a2aClient = new A2AClient(new Uri(card.Url), httpClient); + + return a2aClient.GetAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs index d9fe2a9a1c6..f458f74a1fb 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -29,11 +28,9 @@ public static class A2ACardResolverExtensions /// Retrieves an instance of for an existing A2A agent. ///
    /// - /// Creates an AI agent for an A2A agent whose host supports one of the A2A discovery mechanisms: - /// - /// Well-Known URI - /// Curated Registries (Catalog-Based Discovery) - /// + /// This method can be used to access A2A agents that support the + /// Well-Known URI + /// discovery mechanism. /// /// The to use for the agent creation. /// The to use for HTTP requests. @@ -45,9 +42,6 @@ public static async Task GetAIAgentAsync(this A2ACardResolver resolver, // Obtain the agent card from the resolver. var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false); - // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(agentCard.Url), httpClient); - - return a2aClient.GetAIAgent(name: agentCard.Name, description: agentCard.Description, loggerFactory: loggerFactory); + return await agentCard.GetAIAgentAsync(httpClient, loggerFactory).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs index 300bafbb903..095481c0d49 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs @@ -25,7 +25,7 @@ public static class A2AClientExtensions /// Retrieves an instance of for an existing A2A agent. /// /// - /// This method can be used to create AI agents for A2A agents whose hosts support the + /// This method can be used to access A2A agents that support the /// Direct Configuration / Private Discovery /// discovery mechanism. /// diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj index 64b2646e82f..46e3c97d8fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj @@ -14,8 +14,8 @@ - - + + diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs index 72c77a94a4b..1d5f228fcc2 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/PersistentAgentsClientExtensions.cs @@ -127,6 +127,147 @@ public static async Task GetAIAgentAsync( return persistentAgentsClient.GetAIAgent(persistentAgentResponse, chatOptions, clientFactory); } + /// + /// Gets a runnable agent instance from the provided response containing persistent agent metadata. + /// + /// The client used to interact with persistent agents. Cannot be . + /// The response containing the persistent agent to be converted. Cannot be . + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, ChatClientAgentOptions options, Func? clientFactory = null) + { + if (persistentAgentResponse is null) + { + throw new ArgumentNullException(nameof(persistentAgentResponse)); + } + + return GetAIAgent(persistentAgentsClient, persistentAgentResponse.Value, options, clientFactory); + } + + /// + /// Gets a runnable agent instance from a containing metadata about a persistent agent. + /// + /// The client used to interact with persistent agents. Cannot be . + /// The persistent agent metadata to be converted. Cannot be . + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + public static ChatClientAgent GetAIAgent(this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, ChatClientAgentOptions options, Func? clientFactory = null) + { + if (persistentAgentMetadata is null) + { + throw new ArgumentNullException(nameof(persistentAgentMetadata)); + } + + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var chatClient = persistentAgentsClient.AsIChatClient(persistentAgentMetadata.Id); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + var agentOptions = new ChatClientAgentOptions() + { + Id = persistentAgentMetadata.Id, + Name = options.Name ?? persistentAgentMetadata.Name, + Description = options.Description ?? persistentAgentMetadata.Description, + Instructions = options.Instructions ?? persistentAgentMetadata.Instructions, + ChatOptions = options.ChatOptions, + AIContextProviderFactory = options.AIContextProviderFactory, + ChatMessageStoreFactory = options.ChatMessageStoreFactory, + UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs + }; + + return new ChatClientAgent(chatClient, agentOptions); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace. + public static ChatClientAgent GetAIAgent( + this PersistentAgentsClient persistentAgentsClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the persistent agent. + /// Thrown when or is . + /// Thrown when is empty or whitespace. + public static async Task GetAIAgentAsync( + this PersistentAgentsClient persistentAgentsClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var persistentAgentResponse = await persistentAgentsClient.Administration.GetAgentAsync(agentId, cancellationToken).ConfigureAwait(false); + return persistentAgentsClient.GetAIAgent(persistentAgentResponse, options, clientFactory); + } + /// /// Creates a new server side agent using the provided . /// @@ -234,4 +375,193 @@ public static ChatClientAgent CreateAIAgent( // Get a local proxy for the agent to work with. return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, cancellationToken: cancellationToken); } + + /// + /// Creates a new server side agent using the provided . + /// + /// The to create the agent with. + /// The model to be used by the agent. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or or is . + /// Thrown when is empty or whitespace. + public static ChatClientAgent CreateAIAgent( + this PersistentAgentsClient persistentAgentsClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(model)) + { + throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + + var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent( + model: model, + name: options.Name, + description: options.Description, + instructions: options.Instructions, + tools: toolDefinitionsAndResources.ToolDefinitions, + toolResources: toolDefinitionsAndResources.ToolResources, + temperature: null, + topP: null, + responseFormat: null, + metadata: null, + cancellationToken: cancellationToken); + + if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count)) + { + options = options.Clone(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + } + + // Get a local proxy for the agent to work with. + return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken); + } + + /// + /// Creates a new server side agent using the provided . + /// + /// The to create the agent with. + /// The model to be used by the agent. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the newly created agent. + /// Thrown when or or is . + /// Thrown when is empty or whitespace. + public static async Task CreateAIAgentAsync( + this PersistentAgentsClient persistentAgentsClient, + string model, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (persistentAgentsClient is null) + { + throw new ArgumentNullException(nameof(persistentAgentsClient)); + } + + if (string.IsNullOrWhiteSpace(model)) + { + throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + + var createPersistentAgentResponse = await persistentAgentsClient.Administration.CreateAgentAsync( + model: model, + name: options.Name, + description: options.Description, + instructions: options.Instructions, + tools: toolDefinitionsAndResources.ToolDefinitions, + toolResources: toolDefinitionsAndResources.ToolResources, + temperature: null, + topP: null, + responseFormat: null, + metadata: null, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count)) + { + options = options.Clone(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + } + + // Get a local proxy for the agent to work with. + return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) + { + List? toolDefinitions = null; + ToolResources? toolResources = null; + List? functionToolsAndOtherTools = null; + + if (tools is not null) + { + foreach (AITool tool in tools) + { + switch (tool) + { + case HostedCodeInterpreterTool codeTool: + + toolDefinitions ??= new(); + toolDefinitions.Add(new CodeInterpreterToolDefinition()); + + if (codeTool.Inputs is { Count: > 0 }) + { + foreach (var input in codeTool.Inputs) + { + switch (input) + { + case HostedFileContent hostedFile: + // If the input is a HostedFileContent, we can use its ID directly. + toolResources ??= new(); + toolResources.CodeInterpreter ??= new(); + toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId); + break; + } + } + } + break; + + case HostedFileSearchTool fileSearchTool: + toolDefinitions ??= new(); + toolDefinitions.Add(new FileSearchToolDefinition + { + FileSearch = new() { MaxNumResults = fileSearchTool.MaximumResultCount } + }); + + if (fileSearchTool.Inputs is { Count: > 0 }) + { + foreach (var input in fileSearchTool.Inputs) + { + switch (input) + { + case HostedVectorStoreContent hostedVectorStore: + toolResources ??= new(); + toolResources.FileSearch ??= new(); + toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId); + break; + } + } + } + break; + + case HostedWebSearchTool webSearch when webSearch.AdditionalProperties?.TryGetValue("connectionId", out object? connectionId) is true: + toolDefinitions ??= new(); + toolDefinitions.Add(new BingGroundingToolDefinition(new BingGroundingSearchToolParameters([new BingGroundingSearchConfiguration(connectionId!.ToString())]))); + break; + + default: + functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools.Add(tool); + break; + } + } + } + + return (toolDefinitions, toolResources, functionToolsAndOtherTools); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 115187398d3..c23796ad56e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -2,7 +2,7 @@ $(ProjectsCoreTargetFrameworks) - $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) Microsoft.Agents.AI.Hosting.A2A.AspNetCore preview @@ -12,8 +12,8 @@ - - + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 77aeb6b3bc0..5076e25c05d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -2,9 +2,11 @@ $(ProjectsCoreTargetFrameworks) - $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) Microsoft.Agents.AI.Hosting.A2A preview + Microsoft Agent Framework Hosting A2A + Provides Microsoft Agent Framework support for hosting A2A agents. @@ -16,27 +18,17 @@ - - + + - - - - - - - - Microsoft Agent Framework Hosting A2A - Provides Microsoft Agent Framework support for hosting A2A agents. - diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs new file mode 100644 index 00000000000..f32fcc8db8b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/AIAgentChatCompletionsProcessor.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Buffers; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using OpenAI.Chat; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; + +internal sealed class AIAgentChatCompletionsProcessor +{ + private readonly AIAgent _agent; + + public AIAgentChatCompletionsProcessor(AIAgent agent) + { + this._agent = agent; + } + + public async Task CreateChatCompletionAsync(ChatCompletionOptions chatCompletionOptions, CancellationToken cancellationToken) + { + AgentThread? agentThread = null; // not supported to resolve from conversationId + + var inputItems = chatCompletionOptions.GetMessages(); + var chatMessages = inputItems.AsChatMessages(); + + if (chatCompletionOptions.GetStream()) + { + return new OpenAIStreamingChatCompletionResult(this._agent, chatMessages); + } + + var agentResponse = await this._agent.RunAsync(chatMessages, agentThread, cancellationToken: cancellationToken).ConfigureAwait(false); + return new OpenAIChatCompletionResult(agentResponse); + } + + private sealed class OpenAIChatCompletionResult(AgentRunResponse agentRunResponse) : IResult + { + public async Task ExecuteAsync(HttpContext httpContext) + { + // note: OpenAI SDK types provide their own serialization implementation + // so we cant simply return IResult wrap for the typed-object. + // instead writing to the response body can be done. + + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + var chatResponse = agentRunResponse.AsChatResponse(); + var openAIChatCompletion = chatResponse.AsOpenAIChatCompletion(); + var openAIChatCompletionJsonModel = openAIChatCompletion as IJsonModel; + Debug.Assert(openAIChatCompletionJsonModel is not null); + + var writer = new Utf8JsonWriter(response.BodyWriter, new JsonWriterOptions { SkipValidation = false }); + openAIChatCompletionJsonModel.Write(writer, ModelReaderWriterOptions.Json); + await writer.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + private sealed class OpenAIStreamingChatCompletionResult(AIAgent agent, IEnumerable chatMessages) : IResult + { + public Task ExecuteAsync(HttpContext httpContext) + { + var cancellationToken = httpContext.RequestAborted; + var response = httpContext.Response; + + // Set SSE headers + response.Headers.ContentType = "text/event-stream"; + response.Headers.CacheControl = "no-cache,no-store"; + response.Headers.Connection = "keep-alive"; + response.Headers.ContentEncoding = "identity"; + httpContext.Features.GetRequiredFeature().DisableBuffering(); + + return SseFormatter.WriteAsync( + source: this.GetStreamingResponsesAsync(cancellationToken), + destination: response.Body, + itemFormatter: (sseItem, bufferWriter) => + { + var sseDataJsonModel = (IJsonModel)sseItem.Data; + var json = sseDataJsonModel.Write(ModelReaderWriterOptions.Json); + bufferWriter.Write(json); + }, + cancellationToken); + } + + private async IAsyncEnumerable> GetStreamingResponsesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + AgentThread? agentThread = null; + + var agentRunResponseUpdates = agent.RunStreamingAsync(chatMessages, thread: agentThread, cancellationToken: cancellationToken); + var chatResponseUpdates = agentRunResponseUpdates.AsChatResponseUpdatesAsync(); + await foreach (var streamingChatCompletionUpdate in chatResponseUpdates.AsOpenAIStreamingChatCompletionUpdatesAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new SseItem(streamingChatCompletionUpdate); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs new file mode 100644 index 00000000000..2816e015e05 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ChatCompletions/Utils/ChatCompletionsOptionsExtensions.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Shared.Diagnostics; +using OpenAI.Chat; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions.Utils; + +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Specifically for accessing hidden members")] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1065:Do not raise exceptions in unexpected locations", Justification = "Specifically for accessing hidden members")] +internal static class ChatCompletionsOptionsExtensions +{ + private static readonly Func _getStreamNullable; + private static readonly Func> _getMessages; + + static ChatCompletionsOptionsExtensions() + { + // OpenAI SDK does not have a simple way to get the input as a c# object. + // However, it does parse most of the interesting fields into internal properties of `ChatCompletionsOptions` object. + + // --- Stream (internal bool? Stream { get; set; }) --- + const string streamPropName = "Stream"; + var streamProp = typeof(ChatCompletionOptions).GetProperty(streamPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, streamPropName); + var streamGetter = streamProp.GetGetMethod(nonPublic: true) ?? throw new MissingMethodException($"{streamPropName} getter not found."); + + _getStreamNullable = streamGetter.CreateDelegate>(); + + // --- Messages (internal IList Messages { get; set; }) --- + const string inputPropName = "Messages"; + var inputProp = typeof(ChatCompletionOptions).GetProperty(inputPropName, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMemberException(typeof(ChatCompletionOptions).FullName!, inputPropName); + var inputGetter = inputProp.GetGetMethod(nonPublic: true) + ?? throw new MissingMethodException($"{inputPropName} getter not found."); + + _getMessages = inputGetter.CreateDelegate>>(); + } + + public static IList GetMessages(this ChatCompletionOptions options) + { + Throw.IfNull(options); + return _getMessages(options); + } + + public static bool GetStream(this ChatCompletionOptions options) + { + Throw.IfNull(options); + return _getStreamNullable(options) ?? false; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs new file mode 100644 index 00000000000..b331bd8522e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.ChatCompletions.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel.Primitives; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using Microsoft.Agents.AI.Hosting.OpenAI.ChatCompletions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OpenAI.Chat; + +namespace Microsoft.Agents.AI.Hosting.OpenAI; + +public static partial class EndpointRouteBuilderExtensions +{ + /// + /// Maps OpenAI ChatCompletions API endpoints to the specified for the given . + /// + /// The to add the OpenAI ChatCompletions endpoints to. + /// The name of the AI agent service registered in the dependency injection container. This name is used to resolve the instance from the keyed services. + /// Custom route path for the chat completions endpoint. + public static void MapOpenAIChatCompletions( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("Route")] string? path = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + if (path is null) + { + ValidateAgentName(agentName); + } + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + + path ??= $"/{agentName}/v1/chat/completions"; + var chatCompletionsRouteGroup = endpoints.MapGroup(path); + MapChatCompletions(chatCompletionsRouteGroup, agent); + } + + private static void MapChatCompletions(IEndpointRouteBuilder routeGroup, AIAgent agent) + { + var endpointAgentName = agent.DisplayName; + var chatCompletionsProcessor = new AIAgentChatCompletionsProcessor(agent); + + routeGroup.MapPost("/", async (HttpContext requestContext, CancellationToken cancellationToken) => + { + var requestBinary = await BinaryData.FromStreamAsync(requestContext.Request.Body, cancellationToken).ConfigureAwait(false); + + var chatCompletionOptions = new ChatCompletionOptions(); + var chatCompletionOptionsJsonModel = chatCompletionOptions as IJsonModel; + Debug.Assert(chatCompletionOptionsJsonModel is not null); + + chatCompletionOptions = chatCompletionOptionsJsonModel.Create(requestBinary, ModelReaderWriterOptions.Json); + if (chatCompletionOptions is null) + { + return Results.BadRequest("Invalid request payload."); + } + + return await chatCompletionsProcessor.CreateChatCompletionAsync(chatCompletionOptions, cancellationToken).ConfigureAwait(false); + }).WithName(endpointAgentName + "/CreateChatCompletion"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs index ca58dfd27be..7e3e349f395 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/EndpointRouteBuilderExtensions.Responses.cs @@ -15,9 +15,9 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI; /// -/// Provides extension methods for mapping OpenAI Responses capabilities to an . +/// Provides extension methods for mapping OpenAI capabilities to an . /// -public static class EndpointRouteBuilderExtensions +public static partial class EndpointRouteBuilderExtensions { /// /// Maps OpenAI Responses API endpoints to the specified for the given . diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj index cc618b54bc1..4fbef3aea76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj @@ -2,7 +2,7 @@ $(ProjectsCoreTargetFrameworks) - $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) $(NoWarn);IDE1006;IDE0130;NU1504;OPENAI001 Microsoft.Agents.AI.Hosting.OpenAI alpha @@ -16,7 +16,7 @@ - + all @@ -25,7 +25,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs index 6d5f5283fe9..d45046a8de0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderAgentExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -22,7 +23,7 @@ public static class HostApplicationBuilderAgentExtensions /// The instructions for the agent. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -38,7 +39,7 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu /// The chat client which the agent will use for inference. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, IChatClient chatClient) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -55,7 +56,7 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, string? description, object? chatClientServiceKey) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -75,7 +76,7 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu /// The key to use when resolving the chat client from the service provider. If null, a non-keyed service will be resolved. /// The configured host application builder. /// Thrown when , , or is null. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, string? instructions, object? chatClientServiceKey) { Throw.IfNull(builder); Throw.IfNullOrEmpty(name); @@ -95,7 +96,7 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu /// The configured host application builder. /// Thrown when , , or is null. /// Thrown when the agent factory delegate returns null or an invalid AI agent instance. - public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) + public static IHostedAgentBuilder AddAIAgent(this IHostApplicationBuilder builder, string name, Func createAgentDelegate) { Throw.IfNull(builder); Throw.IfNull(name); @@ -117,7 +118,8 @@ public static IHostApplicationBuilder AddAIAgent(this IHostApplicationBuilder bu // Register the agent by name for discovery. var agentHostBuilder = GetAgentRegistry(builder); agentHostBuilder.AgentNames.Add(name); - return builder; + + return new HostedAgentBuilder(name, builder); } private static LocalAgentRegistry GetAgentRegistry(IHostApplicationBuilder builder) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs new file mode 100644 index 00000000000..ac788776823 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostApplicationBuilderWorkflowExtensions.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Hosting.Local; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for configuring AI workflows in a host application builder. +/// +public static class HostApplicationBuilderWorkflowExtensions +{ + /// + /// Registers a concurrent workflow that executes multiple agents in parallel. + /// + /// The to configure. + /// The unique name for the workflow. + /// A collection of instances representing agents to execute concurrently. + /// An that can be used to further configure the workflow. + /// Thrown when , , or is null. + /// Thrown when or is empty. + public static IHostedWorkflowBuilder AddConcurrentWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders) + { + Throw.IfNullOrEmpty(agentBuilders); + + return builder.AddWorkflow(name, (sp, key) => + { + var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildConcurrent(workflowName: name, agents: agents); + }); + } + + /// + /// Registers a sequential workflow that executes agents in a specific order. + /// + /// The to configure. + /// The unique name for the workflow. + /// A collection of instances representing agents to execute in sequence. + /// An that can be used to further configure the workflow. + /// Thrown when , , or is null. + /// Thrown when or is empty. + public static IHostedWorkflowBuilder AddSequentialWorkflow(this IHostApplicationBuilder builder, string name, IEnumerable agentBuilders) + { + Throw.IfNullOrEmpty(agentBuilders); + + return builder.AddWorkflow(name, (sp, key) => + { + var agents = agentBuilders.Select(ab => sp.GetRequiredKeyedService(ab.Name)); + return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: agents); + }); + } + + /// + /// Registers a custom workflow using a factory delegate. + /// + /// The to configure. + /// The unique name for the workflow. + /// A factory function that creates the instance. The function receives the service provider and workflow name as parameters. + /// An that can be used to further configure the workflow. + /// Thrown when , , or is null. + /// Thrown when is empty. + /// + /// Thrown when the factory delegate returns null or a workflow with a name that doesn't match the expected name. + /// + public static IHostedWorkflowBuilder AddWorkflow(this IHostApplicationBuilder builder, string name, Func createWorkflowDelegate) + { + Throw.IfNull(builder); + Throw.IfNull(name); + Throw.IfNull(createWorkflowDelegate); + + builder.Services.AddKeyedSingleton(name, (sp, key) => + { + Throw.IfNull(key); + var keyString = key as string; + Throw.IfNullOrEmpty(keyString); + var workflow = createWorkflowDelegate(sp, keyString) ?? throw new InvalidOperationException($"The agent factory did not return a valid {nameof(Workflow)} instance for key '{keyString}'."); + if (!string.Equals(workflow.Name, keyString, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"The workflow factory returned workflow with name '{workflow.Name}', but the expected name is '{keyString}'."); + } + + return workflow; + }); + + // Register the workflow by name for discovery. + var workflowRegistry = GetWorkflowRegistry(builder); + workflowRegistry.WorkflowNames.Add(name); + + return new HostedWorkflowBuilder(name, builder); + } + + private static LocalWorkflowRegistry GetWorkflowRegistry(IHostApplicationBuilder builder) + { + var descriptor = builder.Services.FirstOrDefault(s => !s.IsKeyedService && s.ServiceType.Equals(typeof(LocalWorkflowRegistry))); + if (descriptor?.ImplementationInstance is not LocalWorkflowRegistry instance) + { + instance = new LocalWorkflowRegistry(); + ConfigureHostBuilder(builder, instance); + } + + return instance; + } + + private static void ConfigureHostBuilder(IHostApplicationBuilder builder, LocalWorkflowRegistry agentHostBuilderContext) + { + builder.Services.Add(ServiceDescriptor.Singleton(agentHostBuilderContext)); + builder.Services.AddSingleton(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs new file mode 100644 index 00000000000..82e0997c7a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +internal sealed class HostedAgentBuilder : IHostedAgentBuilder +{ + public string Name { get; } + public IHostApplicationBuilder HostApplicationBuilder { get; } + + public HostedAgentBuilder(string name, IHostApplicationBuilder hostApplicationBuilder) + { + this.Name = name; + this.HostApplicationBuilder = hostApplicationBuilder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs new file mode 100644 index 00000000000..e1d87a3836b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilder.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +internal sealed class HostedWorkflowBuilder : IHostedWorkflowBuilder +{ + public string Name { get; } + public IHostApplicationBuilder HostApplicationBuilder { get; } + + public HostedWorkflowBuilder(string name, IHostApplicationBuilder hostApplicationBuilder) + { + this.Name = name; + this.HostApplicationBuilder = hostApplicationBuilder; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs new file mode 100644 index 00000000000..26104c9a571 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedWorkflowBuilderExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides extension methods for to enable additional workflow configuration scenarios. +/// +public static class HostedWorkflowBuilderExtensions +{ + /// + /// Registers the workflow as an AI agent in the dependency injection container. + /// + /// The instance to extend. + /// An that can be used to further configure the agent. + public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder) + => builder.AddAsAIAgent(name: null); + + /// + /// Registers the workflow as an AI agent in the dependency injection container. + /// + /// The instance to extend. + /// The optional name for the AI agent. If not specified, the workflow name is used. + /// An that can be used to further configure the agent. + public static IHostedAgentBuilder AddAsAIAgent(this IHostedWorkflowBuilder builder, string? name) + { + var agentName = name ?? builder.Name; + return builder.HostApplicationBuilder.AddAIAgent(agentName, (sp, key) => + { + var workflow = sp.GetRequiredKeyedService(key); +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + return workflow.AsAgentAsync(name: key).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + }); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs new file mode 100644 index 00000000000..14070bb671d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedAgentBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Represents a builder for configuring AI agents within a hosting environment. +/// +public interface IHostedAgentBuilder +{ + /// + /// Gets the name of the agent being configured. + /// + string Name { get; } + + /// + /// Gets the application host builder for configuring additional services. + /// + IHostApplicationBuilder HostApplicationBuilder { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs new file mode 100644 index 00000000000..405172ffe59 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IHostedWorkflowBuilder.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Represents a builder for configuring workflows within a hosting environment. +/// +public interface IHostedWorkflowBuilder +{ + /// + /// Gets the name of the workflow being configured. + /// + string Name { get; } + + /// + /// Gets the application host builder for configuring additional services. + /// + IHostApplicationBuilder HostApplicationBuilder { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs similarity index 96% rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs index 8d36c16e7cb..0b44ad60cb2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentCatalog.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentCatalog.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI.Hosting.Local; // Implementation of an AgentCatalog which enumerates agents registered in the local service provider. internal sealed class LocalAgentCatalog : AgentCatalog diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs similarity index 80% rename from dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs index 712634a2ba9..df3db8f5544 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/LocalAgentRegistry.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalAgentRegistry.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI.Hosting.Local; internal sealed class LocalAgentRegistry { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs new file mode 100644 index 00000000000..572b41830e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowCatalog.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalWorkflowCatalog : WorkflowCatalog +{ + public readonly HashSet _registeredWorkflows; + private readonly IServiceProvider _serviceProvider; + + public LocalWorkflowCatalog(LocalWorkflowRegistry workflowRegistry, IServiceProvider serviceProvider) + { + this._registeredWorkflows = [.. workflowRegistry.WorkflowNames]; + this._serviceProvider = serviceProvider; + } + + public override async IAsyncEnumerable GetWorkflowsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask.ConfigureAwait(false); + + foreach (var name in this._registeredWorkflows) + { + var workflow = this._serviceProvider.GetKeyedService(name); + if (workflow is not null) + { + yield return workflow; + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs new file mode 100644 index 00000000000..803c24660f8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/LocalWorkflowRegistry.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Local; + +internal sealed class LocalWorkflowRegistry +{ + public HashSet WorkflowNames { get; } = []; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj index 1cec6655883..86f709877d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj @@ -16,6 +16,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs new file mode 100644 index 00000000000..47e09afa8e1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/WorkflowCatalog.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting; + +/// +/// Provides a catalog of registered workflows within the hosting environment. +/// +public abstract class WorkflowCatalog +{ + /// + /// Initializes a new instance of the class. + /// + protected WorkflowCatalog() + { + } + + /// + /// Asynchronously retrieves all registered workflows from the catalog. + /// + /// The to monitor for cancellation requests. The default is . + public abstract IAsyncEnumerable GetWorkflowsAsync(CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs index 94410bb7d0c..71f9b5436bb 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIAssistantClientExtensions.cs @@ -143,6 +143,155 @@ public static async Task GetAIAgentAsync( return assistantClient.GetAIAgent(assistantResponse, chatOptions, clientFactory); } + /// + /// Gets a from a . + /// + /// The assistant client. + /// The client result containing the assistant. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the assistant. + /// or is . + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + ClientResult assistantClientResult, + ChatClientAgentOptions options, + Func? clientFactory = null) + { + if (assistantClientResult is null) + { + throw new ArgumentNullException(nameof(assistantClientResult)); + } + + return assistantClient.GetAIAgent(assistantClientResult.Value, options, clientFactory); + } + + /// + /// Gets a from an . + /// + /// The assistant client. + /// The assistant metadata. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// A instance that can be used to perform operations on the assistant. + /// or is . + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + Assistant assistantMetadata, + ChatClientAgentOptions options, + Func? clientFactory = null) + { + if (assistantMetadata is null) + { + throw new ArgumentNullException(nameof(assistantMetadata)); + } + + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var chatClient = assistantClient.AsIChatClient(assistantMetadata.Id); + + if (clientFactory is not null) + { + chatClient = clientFactory(chatClient); + } + + var mergedOptions = new ChatClientAgentOptions() + { + Id = assistantMetadata.Id, + Name = options.Name ?? assistantMetadata.Name, + Description = options.Description ?? assistantMetadata.Description, + Instructions = options.Instructions ?? assistantMetadata.Instructions, + ChatOptions = options.ChatOptions, + AIContextProviderFactory = options.AIContextProviderFactory, + ChatMessageStoreFactory = options.ChatMessageStoreFactory, + UseProvidedChatClientAsIs = options.UseProvidedChatClientAsIs + }; + + return new ChatClientAgent(chatClient, mergedOptions); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the assistant agent. + /// or is . + /// is empty or whitespace. + public static ChatClientAgent GetAIAgent( + this AssistantClient assistantClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var assistant = assistantClient.GetAssistant(agentId, cancellationToken); + return assistantClient.GetAIAgent(assistant, options, clientFactory); + } + + /// + /// Retrieves an existing server side agent, wrapped as a using the provided . + /// + /// The to create the with. + /// The ID of the server side agent to create a for. + /// Full set of options to configure the agent. + /// Provides a way to customize the creation of the underlying used by the agent. + /// The to monitor for cancellation requests. The default is . + /// A instance that can be used to perform operations on the assistant agent. + /// or is . + /// is empty or whitespace. + public static async Task GetAIAgentAsync( + this AssistantClient assistantClient, + string agentId, + ChatClientAgentOptions options, + Func? clientFactory = null, + CancellationToken cancellationToken = default) + { + if (assistantClient is null) + { + throw new ArgumentNullException(nameof(assistantClient)); + } + + if (string.IsNullOrWhiteSpace(agentId)) + { + throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId)); + } + + if (options is null) + { + throw new ArgumentNullException(nameof(options)); + } + + var assistantResponse = await assistantClient.GetAssistantAsync(agentId, cancellationToken).ConfigureAwait(false); + return assistantClient.GetAIAgent(assistantResponse, options, clientFactory); + } + /// /// Creates an AI agent from an using the OpenAI Assistant API. /// @@ -210,48 +359,34 @@ public static ChatClientAgent CreateAIAgent( Instructions = options.Instructions, }; - if (options.ChatOptions?.Tools is not null) + // Convert AITools to ToolDefinitions and ToolResources + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 }) { - foreach (AITool tool in options.ChatOptions.Tools) - { - switch (tool) - { - // Attempting to set the tools at the agent level throws - // https://github.com/dotnet/extensions/issues/6743 - //case AIFunction aiFunction: - // assistantOptions.Tools.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); - // break; - - case HostedCodeInterpreterTool: - var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); - assistantOptions.Tools.Add(codeInterpreterToolDefinition); - break; - } - } + toolDefinitionsAndResources.ToolDefinitions.ForEach(x => assistantOptions.Tools.Add(x)); } + if (toolDefinitionsAndResources.ToolResources is not null) + { + assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources; + } + + // Create the assistant in the assistant service. var assistantCreateResult = client.CreateAssistant(model, assistantOptions); var assistantId = assistantCreateResult.Value.Id; - var agentOptions = new ChatClientAgentOptions() - { - Id = assistantId, - Name = options.Name, - Description = options.Description, - Instructions = options.Instructions, - ChatOptions = options.ChatOptions?.Tools is null ? null : new ChatOptions() - { - Tools = options.ChatOptions.Tools, - } - }; - + // Build the local agent object. var chatClient = client.AsIChatClient(assistantId); - if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + var agentOptions = options.Clone(); + agentOptions.Id = assistantId; + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } @@ -321,48 +456,101 @@ public static async Task CreateAIAgentAsync( Instructions = options.Instructions, }; - if (options.ChatOptions?.Tools is not null) + // Convert AITools to ToolDefinitions and ToolResources + var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools); + if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 } toolDefinitions) { - foreach (AITool tool in options.ChatOptions.Tools) - { - switch (tool) - { - // Attempting to set the tools at the agent level throws - // https://github.com/dotnet/extensions/issues/6743 - //case AIFunction aiFunction: - // assistantOptions.Tools.Add(ToOpenAIAssistantsFunctionToolDefinition(aiFunction)); - // break; - - case HostedCodeInterpreterTool: - var codeInterpreterToolDefinition = new CodeInterpreterToolDefinition(); - assistantOptions.Tools.Add(codeInterpreterToolDefinition); - break; - } - } + toolDefinitions.ForEach(x => assistantOptions.Tools.Add(x)); + } + if (toolDefinitionsAndResources.ToolResources is not null) + { + assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources; } + // Create the assistant in the assistant service. var assistantCreateResult = await client.CreateAssistantAsync(model, assistantOptions).ConfigureAwait(false); var assistantId = assistantCreateResult.Value.Id; - var agentOptions = new ChatClientAgentOptions() - { - Id = assistantId, - Name = options.Name, - Description = options.Description, - Instructions = options.Instructions, - ChatOptions = options.ChatOptions?.Tools is null ? null : new ChatOptions() - { - Tools = options.ChatOptions.Tools, - } - }; - + // Build the local agent object. var chatClient = client.AsIChatClient(assistantId); - if (clientFactory is not null) { chatClient = clientFactory(chatClient); } + var agentOptions = options.Clone(); + agentOptions.Id = assistantId; + options.ChatOptions ??= new ChatOptions(); + options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools; + return new ChatClientAgent(chatClient, agentOptions, loggerFactory); } + + private static (List? ToolDefinitions, ToolResources? ToolResources, List? FunctionToolsAndOtherTools) ConvertAIToolsToToolDefinitions(IList? tools) + { + List? toolDefinitions = null; + ToolResources? toolResources = null; + List? functionToolsAndOtherTools = null; + + if (tools is not null) + { + foreach (AITool tool in tools) + { + switch (tool) + { + case HostedCodeInterpreterTool codeTool: + + toolDefinitions ??= new(); + toolDefinitions.Add(new CodeInterpreterToolDefinition()); + + if (codeTool.Inputs is { Count: > 0 }) + { + foreach (var input in codeTool.Inputs) + { + switch (input) + { + case HostedFileContent hostedFile: + // If the input is a HostedFileContent, we can use its ID directly. + toolResources ??= new(); + toolResources.CodeInterpreter ??= new(); + toolResources.CodeInterpreter.FileIds.Add(hostedFile.FileId); + break; + } + } + } + break; + + case HostedFileSearchTool fileSearchTool: + toolDefinitions ??= new(); + toolDefinitions.Add(new FileSearchToolDefinition + { + MaxResults = fileSearchTool.MaximumResultCount, + }); + + if (fileSearchTool.Inputs is { Count: > 0 }) + { + foreach (var input in fileSearchTool.Inputs) + { + switch (input) + { + case HostedVectorStoreContent hostedVectorStore: + toolResources ??= new(); + toolResources.FileSearch ??= new(); + toolResources.FileSearch.VectorStoreIds.Add(hostedVectorStore.VectorStoreId); + break; + } + } + } + break; + + default: + functionToolsAndOtherTools ??= new(); + functionToolsAndOtherTools.Add(tool); + break; + } + } + } + + return (toolDefinitions, toolResources, functionToolsAndOtherTools); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs index 141233c2cb4..ac44890c1c0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/AzureAgentProvider.cs @@ -37,26 +37,29 @@ public sealed class AzureAgentProvider(string projectEndpoint, TokenCredential p /// public override async Task CreateConversationAsync(CancellationToken cancellationToken = default) { - PersistentAgentThread conversation = await this.GetAgentsClient().Threads.CreateThreadAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + PersistentAgentThread conversation = + await this.GetAgentsClient().Threads.CreateThreadAsync( + messages: null, + toolResources: null, + metadata: null, + cancellationToken).ConfigureAwait(false); + return conversation.Id; } /// - public override Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) + public override async Task CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) { - // TODO: Switch to asynchronous "CreateMessageAsync", when fix properly applied: - // BUG: https://github.com/Azure/azure-sdk-for-net/issues/52571 - // PR: https://github.com/Azure/azure-sdk-for-net/pull/52653 PersistentThreadMessage newMessage = - this.GetAgentsClient().Messages.CreateMessage( + await this.GetAgentsClient().Messages.CreateMessageAsync( conversationId, role: s_roleMap[conversationMessage.Role.Value.ToUpperInvariant()], contentBlocks: GetContent(), attachments: null, metadata: GetMetadata(), - cancellationToken); + cancellationToken).ConfigureAwait(false); - return Task.FromResult(ToChatMessage(newMessage)); + return ToChatMessage(newMessage); Dictionary? GetMetadata() { @@ -78,6 +81,7 @@ IEnumerable GetContent() TextContent textContent => new MessageInputTextBlock(textContent.Text), HostedFileContent fileContent => new MessageInputImageFileBlock(new MessageImageFileParam(fileContent.FileId)), UriContent uriContent when uriContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(uriContent.Uri.ToString())), + DataContent dataContent when dataContent.Uri is not null => new MessageInputImageUriBlock(new MessageImageUriParam(dataContent.Uri)), _ => null // Unsupported content type }; @@ -90,8 +94,41 @@ IEnumerable GetContent() } /// - public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) => - await this.GetAgentsClient().GetAIAgentAsync(agentId, chatOptions: null, cancellationToken: cancellationToken).ConfigureAwait(false); + public override async Task GetAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + ChatClientAgent agent = + await this.GetAgentsClient().GetAIAgentAsync( + agentId, + new ChatOptions() + { + AllowMultipleToolCalls = this.AllowMultipleToolCalls, + }, + clientFactory: null, + cancellationToken).ConfigureAwait(false); + + FunctionInvokingChatClient? functionInvokingClient = agent.GetService(); + if (functionInvokingClient is not null) + { + // Allow concurrent invocations if configured + functionInvokingClient.AllowConcurrentInvocation = this.AllowConcurrentInvocation; + // Allows the caller to respond with function responses + functionInvokingClient.TerminateOnUnknownCalls = true; + // Make functions available for execution. Doesn't change what tool is available for any given agent. + if (this.Functions is not null) + { + if (functionInvokingClient.AdditionalTools is null) + { + functionInvokingClient.AdditionalTools = [.. this.Functions]; + } + else + { + functionInvokingClient.AdditionalTools = [.. functionInvokingClient.AdditionalTools, .. this.Functions]; + } + } + } + + return agent; + } /// public override async Task GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs new file mode 100644 index 00000000000..b1fe34eda16 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents a request for user input. +/// +public sealed class AgentToolRequest +{ + /// + /// The name of the agent associated with the tool request. + /// + public string AgentName { get; } + + /// + /// A list of tool requests. + /// + public IList FunctionCalls { get; } + + [JsonConstructor] + internal AgentToolRequest(string agentName, IList? functionCalls = null) + { + this.AgentName = agentName; + this.FunctionCalls = functionCalls ?? []; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs new file mode 100644 index 00000000000..29a7f989541 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/AgentToolResponse.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Events; + +/// +/// Represents a user input response. +/// +public sealed class AgentToolResponse +{ + /// + /// The name of the agent associated with the tool response. + /// + public string AgentName { get; } + + /// + /// A list of tool responses. + /// + public IList FunctionResults { get; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + internal AgentToolResponse(string agentName, IList functionResults) + { + this.AgentName = agentName; + this.FunctionResults = functionResults; + } + + /// + /// Factory method to create an from an + /// Ensures that all function calls in the request have a corresponding result. + /// + /// The tool request. + /// On or more function results + /// An that can be provided to the workflow. + /// Not all have a corresponding . + public static AgentToolResponse Create(AgentToolRequest toolRequest, params IEnumerable functionResults) + { + HashSet callIds = [.. toolRequest.FunctionCalls.Select(call => call.CallId)]; + HashSet resultIds = [.. functionResults.Select(call => call.CallId)]; + if (!callIds.SetEquals(resultIds)) + { + throw new DeclarativeActionException($"Missing results for: {string.Join(",", callIds.Except(resultIds))}"); + } + return new AgentToolResponse(toolRequest.AgentName, [.. functionResults]); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputRequest.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputRequest.cs index 881112c4e9b..45ce8c217d1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputRequest.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json.Serialization; + namespace Microsoft.Agents.AI.Workflows.Declarative.Events; /// @@ -12,6 +14,7 @@ public sealed class InputRequest /// public string Prompt { get; } + [JsonConstructor] internal InputRequest(string prompt) { this.Prompt = prompt; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputResponse.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputResponse.cs index b2db2fff9f0..a34d41610e8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Events/InputResponse.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Text.Json.Serialization; + namespace Microsoft.Agents.AI.Workflows.Declarative.Events; /// @@ -16,6 +18,7 @@ public sealed class InputResponse /// Initializes a new instance of the class. /// /// The response value. + [JsonConstructor] public InputResponse(string value) { this.Value = value; 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 20a825454a2..5b6bbbc2978 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs @@ -4,12 +4,21 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; internal static class AgentProviderExtensions { + private static readonly HashSet s_failureStatus = + [ + Azure.AI.Agents.Persistent.RunStatus.Failed, + Azure.AI.Agents.Persistent.RunStatus.Cancelled, + Azure.AI.Agents.Persistent.RunStatus.Cancelling, + Azure.AI.Agents.Persistent.RunStatus.Expired, + ]; + public static async ValueTask InvokeAgentAsync( this WorkflowAgentProvider agentProvider, string executorId, @@ -51,9 +60,16 @@ inputMessages is not null ? updates.Add(update); + if (update.RawRepresentation is ChatResponseUpdate chatUpdate && + chatUpdate.RawRepresentation is RunUpdate runUpdate && + s_failureStatus.Contains(runUpdate.Value.Status)) + { + throw new DeclarativeActionException($"Unexpected failure invoking agent, run {runUpdate.Value.Status}: {agent.Name ?? agent.Id} [{runUpdate.Value.Id}/{conversationId}]"); + } + if (autoSend) { - await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false); } } @@ -61,7 +77,7 @@ inputMessages is not null ? if (autoSend) { - await context.AddEventAsync(new AgentRunResponseEvent(executorId, response)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false); } if (autoSend && !isWorkflowConversation && workflowConversationId is not null) @@ -87,7 +103,7 @@ async ValueTask AssignConversationIdAsync(string? assignValue) { conversationId = assignValue; - await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false); + await context.QueueConversationUpdateAsync(conversationId, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs index c8df8c277a6..e841bd4bbb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ChatMessageExtensions.cs @@ -94,6 +94,9 @@ public static ChatMessage ToChatMessage(this RecordDataValue message) => public static ChatMessage ToChatMessage(this StringDataValue message) => new(ChatRole.User, message.Value); + public static ChatMessage ToChatMessage(this IEnumerable functionResults) => + new(ChatRole.Tool, [.. functionResults]); + public static AdditionalPropertiesDictionary? ToMetadata(this RecordDataValue? metadata) { if (metadata is null) @@ -131,7 +134,7 @@ public static ChatRole ToChatRole(this AgentMessageRole role) => return contentType switch { - AgentMessageContentType.ImageUrl => new UriContent(contentValue, "image/*"), + AgentMessageContentType.ImageUrl => GetImageContent(contentValue), AgentMessageContentType.ImageFile => new HostedFileContent(contentValue), _ => new TextContent(contentValue) }; @@ -169,7 +172,7 @@ private static IEnumerable GetContent(this RecordDataValue message) yield return contentItem?.GetProperty(TypeSchema.Message.Fields.ContentType)?.Value switch { - TypeSchema.Message.ContentTypes.ImageUrl => new UriContent(contentValue.Value, "image/*"), + TypeSchema.Message.ContentTypes.ImageUrl => GetImageContent(contentValue.Value), TypeSchema.Message.ContentTypes.ImageFile => new HostedFileContent(contentValue.Value), _ => new TextContent(contentValue.Value) }; @@ -177,6 +180,11 @@ private static IEnumerable GetContent(this RecordDataValue message) } } + private static AIContent GetImageContent(string uriText) => + uriText.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ? + new DataContent(uriText, "image/*") : + new UriContent(uriText, "image/*"); + private static TValue? GetProperty(this RecordDataValue record, string name) where TValue : DataValue { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs index df3432a68f1..a520593144e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/DataValueExtensions.cs @@ -148,11 +148,9 @@ public static RecordDataValue ToRecordValue(this IDictionary value) IEnumerable> GetFields() { - yield return new KeyValuePair(TypeSchema.Discriminator, nameof(ExpandoObject).ToDataValue()); - - foreach (string key in value.Keys) + foreach (DictionaryEntry entry in value) { - yield return new KeyValuePair(key, value[key].ToDataValue()); + yield return new KeyValuePair((string)entry.Key, entry.Value.ToDataValue()); } } } @@ -252,7 +250,6 @@ private static object ToObject(this RecordDataValue record) private static Dictionary ToDictionary(this RecordDataValue record) { Dictionary result = []; - result[TypeSchema.Discriminator] = nameof(ExpandoObject); foreach (KeyValuePair property in record.Properties) { result[property.Key] = property.Value.ToObject(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ExpandoObjectExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ExpandoObjectExtensions.cs new file mode 100644 index 00000000000..b279272c7b9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ExpandoObjectExtensions.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; + +internal static class ExpandoObjectExtensions +{ + public static RecordType ToRecordType(this ExpandoObject value) + { + RecordType recordType = RecordType.Empty(); + + foreach (KeyValuePair property in value) + { + recordType.Add(property.Key, property.Value.GetFormulaType()); + } + + return recordType; + } + + public static RecordValue ToRecord(this ExpandoObject value) => + FormulaValue.NewRecordFromFields( + value.Select( + property => new NamedValue(property.Key, property.Value.ToFormula()))); +} 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 7c0dee450ae..e0425bfbeca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/FormulaValueExtensions.cs @@ -123,6 +123,8 @@ public static DataType ToDataType(this FormulaType type) => _ => DataType.Unspecified, }; + public static object AsPortable(this FormulaValue? value) => (value?.ToObject()).AsPortable(); + public static string Format(this FormulaValue value) => value switch { @@ -155,12 +157,16 @@ public static RecordValue ToRecord(this IDictionary value) IEnumerable GetFields() { - foreach (string key in value.Keys) + foreach (DictionaryEntry entry in value) { - yield return new NamedValue(key, value[key].ToFormula()); + yield return new NamedValue((string)entry.Key, entry.Value.ToFormula()); } } } + public static RecordValue ToRecord(this Dictionary value) => + FormulaValue.NewRecordFromFields( + value.Select( + property => new NamedValue(property.Key, property.Value.ToFormula()))); private static RecordDataType ToDataType(this RecordType record) { @@ -182,21 +188,6 @@ private static TableDataType ToDataType(this TableType table) return tableType; } - private static RecordType ToRecordType(this ExpandoObject value) - { - RecordType recordType = RecordType.Empty(); - foreach (KeyValuePair property in value) - { - recordType.Add(property.Key, property.Value.GetFormulaType()); - } - return recordType; - } - - private static RecordValue ToRecord(this ExpandoObject value) => - FormulaValue.NewRecordFromFields( - value.Select( - property => new NamedValue(property.Key, property.Value.ToFormula()))); - private static TableType ToTableType(this IEnumerable value) { foreach (object? element in value) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs index 4e2815f218e..720636178e9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/IWorkflowContextExtensions.cs @@ -14,23 +14,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; internal static class IWorkflowContextExtensions { - public static ValueTask RaiseInvocationEventAsync(this IWorkflowContext context, DialogAction action, string? priorEventId = null) => - context.AddEventAsync(new DeclarativeActionInvokedEvent(action, priorEventId)); + public static ValueTask RaiseInvocationEventAsync(this IWorkflowContext context, DialogAction action, string? priorEventId = null, CancellationToken cancellationToken = default) => + context.AddEventAsync(new DeclarativeActionInvokedEvent(action, priorEventId), cancellationToken); - public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, DialogAction action) => - context.AddEventAsync(new DeclarativeActionCompletedEvent(action)); - - public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, object? result = null, CancellationToken cancellationToken = default) => - context.SendMessageAsync(new ActionExecutorResult(id, result)); - - public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath) => - context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), UnassignedValue.Instance, Throw.IfNull(variablePath.NamespaceAlias)); - - public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value) => - context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias)); - - public static ValueTask QueueSystemUpdateAsync(this IWorkflowContext context, string key, TValue? value) => - DeclarativeContext(context).QueueSystemUpdateAsync(key, value); + public static ValueTask RaiseCompletionEventAsync(this IWorkflowContext context, DialogAction action, CancellationToken cancellationToken = default) => + context.AddEventAsync(new DeclarativeActionCompletedEvent(action), cancellationToken); public static FormulaValue ReadState(this IWorkflowContext context, PropertyPath variablePath) => context.ReadState(Throw.IfNull(variablePath.VariableName), Throw.IfNull(variablePath.NamespaceAlias)); @@ -38,18 +26,47 @@ public static FormulaValue ReadState(this IWorkflowContext context, PropertyPath public static FormulaValue ReadState(this IWorkflowContext context, string key, string? scopeName = null) => DeclarativeContext(context).State.Get(key, scopeName); - public static async ValueTask QueueConversationUpdateAsync(this IWorkflowContext context, string conversationId, bool isExternal = false) + public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, CancellationToken cancellationToken = default) => + context.SendResultMessageAsync(id, result: null, cancellationToken); + + public static ValueTask SendResultMessageAsync(this IWorkflowContext context, string id, object? result, CancellationToken cancellationToken = default) => + context.SendMessageAsync(new ActionExecutorResult(id, result), targetId: null, cancellationToken); + + public static ValueTask QueueStateResetAsync(this IWorkflowContext context, PropertyPath variablePath, CancellationToken cancellationToken = default) => + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), UnassignedValue.Instance, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); + + public static ValueTask QueueStateUpdateAsync(this IWorkflowContext context, PropertyPath variablePath, TValue? value, CancellationToken cancellationToken = default) => + context.QueueStateUpdateAsync(Throw.IfNull(variablePath.VariableName), value, Throw.IfNull(variablePath.NamespaceAlias), cancellationToken); + + public static async ValueTask QueueEnvironmentUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) + { + DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); + await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.Environment, allowSystem: true, cancellationToken).ConfigureAwait(false); + declarativeContext.State.Bind(); + } + + public static async ValueTask QueueSystemUpdateAsync(this IWorkflowContext context, string key, TValue? value, CancellationToken cancellationToken = default) + { + DeclarativeWorkflowContext declarativeContext = DeclarativeContext(context); + await declarativeContext.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true, cancellationToken).ConfigureAwait(false); + declarativeContext.State.Bind(); + } + + public static ValueTask QueueConversationUpdateAsync(this IWorkflowContext context, string conversationId, CancellationToken cancellationToken = default) => + context.QueueConversationUpdateAsync(conversationId, isExternal: false, cancellationToken); + + public static async ValueTask QueueConversationUpdateAsync(this IWorkflowContext context, string conversationId, bool isExternal = false, CancellationToken cancellationToken = default) { RecordValue conversation = (RecordValue)context.ReadState(SystemScope.Names.Conversation, VariableScopeNames.System); if (isExternal) { conversation.UpdateField("Id", FormulaValue.New(conversationId)); - await context.QueueSystemUpdateAsync(SystemScope.Names.Conversation, conversation).ConfigureAwait(false); - await context.QueueSystemUpdateAsync(SystemScope.Names.ConversationId, FormulaValue.New(conversationId)).ConfigureAwait(false); + await context.QueueSystemUpdateAsync(SystemScope.Names.Conversation, conversation, cancellationToken).ConfigureAwait(false); + await context.QueueSystemUpdateAsync(SystemScope.Names.ConversationId, FormulaValue.New(conversationId), cancellationToken).ConfigureAwait(false); } - await context.AddEventAsync(new ConversationUpdateEvent(conversationId) { IsWorkflow = isExternal }).ConfigureAwait(false); + await context.AddEventAsync(new ConversationUpdateEvent(conversationId) { IsWorkflow = isExternal }, cancellationToken).ConfigureAwait(false); } public static bool IsWorkflowConversation( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs index c4350bb84cf..0ce1e2a28e5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs @@ -7,6 +7,7 @@ using System.Text.Json; using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; using Microsoft.PowerFx.Types; namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; @@ -46,6 +47,56 @@ IEnumerable TypedElements() } } + public static object AsPortable(this object? value) => + value switch + { + null => UnassignedValue.Instance, + string or + bool or + int or + float or + long or + decimal or + double or + DateTime or + TimeSpan => + value, + ChatMessage messageValue => messageValue.ToRecord().AsPortable(), + IDictionary objectValue => objectValue.AsPortable(), + IDictionary recordValue => recordValue.AsPortable(), + IEnumerable tableValue => tableValue.AsPortable(), + _ => throw new DeclarativeModelException($"Unsupported data type: {value.GetType().Name}"), + }; + + public static object AsPortable(this IDictionary value) => value.ToDictionary(kvp => kvp.Key, kvp => new PortableValue(kvp.Value.AsPortable())); + + public static object AsPortable(this IDictionary value) + { + return GetEntries().ToDictionary(kvp => kvp.Key, kvp => new PortableValue(kvp.Value.AsPortable())); + + IEnumerable> GetEntries() + { + foreach (DictionaryEntry entry in value) + { + yield return new KeyValuePair((string)entry.Key, entry.Value); + } + } + } + + public static object AsPortable(this IEnumerable value) + { + return GetValues().ToArray(); + + IEnumerable GetValues() + { + IEnumerator enumerator = value.GetEnumerator(); + while (enumerator.MoveNext()) + { + yield return new PortableValue(enumerator.Current.AsPortable()); + } + } + } + public static object? ConvertType(this object? sourceValue, VariableType targetType) { if (!targetType.IsValid()) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs new file mode 100644 index 00000000000..17e7579d9f3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/PortableValueExtensions.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Extensions; + +internal static class PortableValueExtensions +{ + public static FormulaValue ToFormula(this PortableValue value) => + value.TypeId switch + { + null => FormulaValue.NewBlank(), + _ when value.TypeId.IsMatch() => FormulaValue.NewBlank(), + _ when value.IsType(out string? stringValue) => FormulaValue.New(stringValue), + _ when value.IsSystemType(out bool? boolValue) => FormulaValue.New(boolValue.Value), + _ when value.IsSystemType(out int? intValue) => FormulaValue.New(intValue.Value), + _ when value.IsSystemType(out long? longValue) => FormulaValue.New(longValue.Value), + _ when value.IsSystemType(out decimal? decimalValue) => FormulaValue.New(decimalValue.Value), + _ when value.IsSystemType(out float? floatValue) => FormulaValue.New(floatValue.Value), + _ when value.IsSystemType(out double? doubleValue) => FormulaValue.New(doubleValue.Value), + _ when value.IsParentType(out Dictionary? recordValue) => recordValue.ToRecord(), + _ when value.IsParentType(out IDictionary? recordValue) => recordValue.ToRecord(), + _ when value.IsType(out PortableValue[]? tableValue) => tableValue.ToTable(), + _ when value.IsType(out ChatMessage? messageValue) => messageValue.ToRecord(), + _ when value.IsType(out DateTime dateValue) => + dateValue.TimeOfDay == TimeSpan.Zero ? + FormulaValue.NewDateOnly(dateValue.Date) : + FormulaValue.New(dateValue), + _ when value.IsType(out TimeSpan timeValue) => FormulaValue.New(timeValue), + _ => throw new DeclarativeModelException($"Unsupported portable type: {value.TypeId.TypeName}"), + }; + + private static TableValue ToTable(this PortableValue[] values) + { + FormulaValue[] formulaValues = values.Select(value => value.ToFormula()).ToArray(); + if (formulaValues[0] is RecordValue recordValue) + { + return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType()); + } + + return + formulaValues[0] switch + { + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + PrimitiveValue => NewSingleColumnTable(), + _ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"), + }; + + TableValue NewSingleColumnTable() => + FormulaValue.NewSingleColumnTable(formulaValues.OfType>()); + } + + public static bool IsSystemType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct + { + if (value.TypeId.IsMatch() || value.TypeId.IsMatch(typeof(TValue).UnderlyingSystemType)) + { + return value.Is(out typedValue); + } + + typedValue = default; + return false; + } + + public static bool IsType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) + { + if (value.TypeId.IsMatch()) + { + return value.Is(out typedValue); + } + + typedValue = default; + return false; + } + + public static bool IsParentType(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) + { + if (value.TypeId.IsMatchPolymorphic(typeof(TValue))) + { + return value.Is(out typedValue); + } + + typedValue = default; + return false; + } + + private static RecordType ParseRecordType(this RecordValue record) + { + RecordType recordType = RecordType.Empty(); + foreach (NamedValue property in record.Fields) + { + recordType = recordType.Add(property.Name, property.Value.Type); + } + return recordType; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 95604846ec6..3b0169bc00e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -61,7 +61,7 @@ public ValueTask ResetAsync() } /// - public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context) + public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (this.Model.Disabled) { @@ -69,7 +69,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf return; } - await context.RaiseInvocationEventAsync(this.Model, message.ExecutorId).ConfigureAwait(false); + await context.RaiseInvocationEventAsync(this.Model, message.ExecutorId, cancellationToken).ConfigureAwait(false); try { @@ -78,7 +78,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf if (this.EmitResultEvent) { - await context.SendResultMessageAsync(this.Id, result).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, result, cancellationToken).ConfigureAwait(false); } } catch (DeclarativeActionException exception) @@ -95,7 +95,7 @@ public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkf { if (this.IsDiscreteAction) { - await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs index 3b010324673..60e50e6abe4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowContext.cs @@ -1,8 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Frozen; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Kit; @@ -32,16 +35,21 @@ public DeclarativeWorkflowContext(IWorkflowContext source, WorkflowFormulaState public IReadOnlyDictionary? TraceContext => this.Source.TraceContext; /// - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => this.Source.AddEventAsync(workflowEvent); + public bool ConcurrentRunsEnabled => this.Source.ConcurrentRunsEnabled; /// - public ValueTask YieldOutputAsync(object output) => this.Source.YieldOutputAsync(output); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + => this.Source.AddEventAsync(workflowEvent, cancellationToken); + + /// + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) + => this.Source.YieldOutputAsync(output, cancellationToken); /// public ValueTask RequestHaltAsync() => this.Source.RequestHaltAsync(); /// - public async ValueTask QueueClearScopeAsync(string? scopeName = null) + public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) { if (scopeName is not null) { @@ -50,12 +58,12 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null) // Copy keys to array to avoid modifying collection during enumeration. foreach (string key in this.State.Keys(scopeName).ToArray()) { - await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName).ConfigureAwait(false); + await this.UpdateStateAsync(key, UnassignedValue.Instance, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); } } else { - await this.Source.QueueClearScopeAsync(scopeName).ConfigureAwait(false); + await this.Source.QueueClearScopeAsync(scopeName, cancellationToken).ConfigureAwait(false); } this.State.Bind(); @@ -63,44 +71,73 @@ public async ValueTask QueueClearScopeAsync(string? scopeName = null) } /// - public async ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) + public async ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) { - await this.UpdateStateAsync(key, value, scopeName).ConfigureAwait(false); + await this.UpdateStateAsync(key, value, scopeName, allowSystem: false, cancellationToken).ConfigureAwait(false); this.State.Bind(); } - public async ValueTask QueueSystemUpdateAsync(string key, TValue? value) - { - await this.UpdateStateAsync(key, value, VariableScopeNames.System, allowSystem: true).ConfigureAwait(false); - this.State.Bind(); - } + private bool IsManagedScope(string? scopeName) => scopeName is not null && VariableScopeNames.IsValidName(scopeName); /// - public async ValueTask ReadStateAsync(string key, string? scopeName = null) + public async ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) { - bool isManagedScope = - scopeName is not null && // null scope cannot be managed - VariableScopeNames.IsValidName(scopeName); - return typeof(TValue) switch { // Not a managed scope, just pass through. This is valid when a declarative // workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized). - _ when !isManagedScope => await this.Source.ReadStateAsync(key, scopeName).ConfigureAwait(false), + _ when !this.IsManagedScope(scopeName) => await this.Source.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false), // Retrieve formula values directly from the managed state to avoid conversion. _ when typeof(TValue) == typeof(FormulaValue) => (TValue?)(object?)this.State.Get(key, scopeName), // Retrieve native types from the source context to avoid conversion. - _ => await this.Source.ReadStateAsync(key, scopeName).ConfigureAwait(false), + _ => await this.Source.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false), + }; + } + + public async ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + return typeof(TValue) switch + { + // Not a managed scope, just pass through. This is valid when a declarative + // workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized). + _ when !this.IsManagedScope(scopeName) => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false), + // Retrieve formula values directly from the managed state to avoid conversion. + _ when typeof(TValue) == typeof(FormulaValue) => await EnsureFormulaValueAsync().ConfigureAwait(false), + // Retrieve native types from the source context to avoid conversion. + _ => await this.Source.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false), }; + + async ValueTask EnsureFormulaValueAsync() + { + Debug.Assert(typeof(TValue) == typeof(FormulaValue), "It is a bug to call this method with TValue not === FormulaValue"); + FormulaValue? result = this.State.Get(key, scopeName); + + if (result is null or BlankValue) + { + result = initialStateFactory() as FormulaValue; + if (result is null) + { + throw new InvalidOperationException($"The initial state factory for key '{key}' in scope '{scopeName}' did not return a FormulaValue."); + } + + this.State.Set(key, result, scopeName); + await this.Source.QueueStateUpdateAsync(key, result.AsPortable(), scopeName, cancellationToken) + .ConfigureAwait(false); + } + + return (TValue)(object)result!; // The null analyzer is confused here, but it is impossible to hit this line with result is null + } } /// - public ValueTask> ReadStateKeysAsync(string? scopeName = null) => this.Source.ReadStateKeysAsync(scopeName); + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this.Source.ReadStateKeysAsync(scopeName, cancellationToken); /// - public ValueTask SendMessageAsync(object message, string? targetId = null) => this.Source.SendMessageAsync(message, targetId); + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) + => this.Source.SendMessageAsync(message, targetId, cancellationToken); - private ValueTask UpdateStateAsync(string key, T? value, string? scopeName, bool allowSystem = true) + public ValueTask UpdateStateAsync(string key, T? value, string? scopeName, bool allowSystem, CancellationToken cancellationToken = default) { bool isManagedScope = scopeName is not null && // null scope cannot be managed @@ -110,7 +147,7 @@ scopeName is not null && // null scope cannot be managed { // Not a managed scope, just pass through. This is valid when a declarative // workflow has been ejected to code (where DeclarativeWorkflowContext is also utilized). - return this.Source.QueueStateUpdateAsync(key, value, scopeName); + return this.Source.QueueStateUpdateAsync(key, value, scopeName, cancellationToken); } if (!ManagedScopes.Contains(scopeName!) && !allowSystem) @@ -134,7 +171,7 @@ ValueTask QueueEmptyStateAsync() { this.State.Set(key, FormulaValue.NewBlank(), scopeName); } - return this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName); + return this.Source.QueueStateUpdateAsync(key, UnassignedValue.Instance, scopeName, cancellationToken); } ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) @@ -143,27 +180,32 @@ ValueTask QueueFormulaStateAsync(FormulaValue formulaValue) { this.State.Set(key, formulaValue, scopeName); } - return this.Source.QueueStateUpdateAsync(key, formulaValue.ToObject(), scopeName); + + return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); } ValueTask QueueDataValueStateAsync(DataValue dataValue) { + FormulaValue formulaValue = dataValue.ToFormula(); + if (isManagedScope) { - FormulaValue formulaValue = dataValue.ToFormula(); this.State.Set(key, formulaValue, scopeName); } - return this.Source.QueueStateUpdateAsync(key, dataValue.ToObject(), scopeName); + + return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); } - ValueTask QueueNativeStateAsync(object? rawValue) + ValueTask QueueNativeStateAsync(object rawValue) { + FormulaValue formulaValue = rawValue.ToFormula(); + if (isManagedScope) { - FormulaValue formulaValue = rawValue.ToFormula(); this.State.Set(key, formulaValue, scopeName); } - return this.Source.QueueStateUpdateAsync(key, rawValue, scopeName); + + return this.Source.QueueStateUpdateAsync(key, formulaValue.AsPortable(), scopeName, cancellationToken); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index e30bb94a014..7436e64446e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; @@ -24,7 +25,7 @@ public ValueTask ResetAsync() return default; } - public override async ValueTask HandleAsync(TInput message, IWorkflowContext context) + public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) { // No state to restore if we're starting from the beginning. state.SetInitialized(); @@ -35,13 +36,13 @@ public override async ValueTask HandleAsync(TInput message, IWorkflowContext con string? conversationId = options.ConversationId; if (string.IsNullOrWhiteSpace(conversationId)) { - conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken: default).ConfigureAwait(false); + conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); } - await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true).ConfigureAwait(false); + await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false); - await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken: default).ConfigureAwait(false); - await declarativeContext.SetLastMessageAsync(input).ConfigureAwait(false); + ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false); + await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false); - await context.SendResultMessageAsync(this.Id).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs index 21f1f1aa4d2..1d9a2c75528 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Kit; @@ -11,11 +12,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter; internal sealed class DelegateActionExecutor(string actionId, WorkflowFormulaState state, DelegateAction? action = null, bool emitResult = true) : DelegateActionExecutor(actionId, state, action, emitResult) { - public override ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context) + public override ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken) { Debug.WriteLine($"RESULT #{this.Id} - {message.Result ?? "(null)"}"); - return base.HandleAsync(message, context); + return base.HandleAsync(message, context, cancellationToken); } } @@ -39,16 +40,16 @@ public ValueTask ResetAsync() return default; } - public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (this._action is not null) { - await this._action.Invoke(new DeclarativeWorkflowContext(context, this._state), message, default).ConfigureAwait(false); + await this._action.Invoke(new DeclarativeWorkflowContext(context, this._state), message, cancellationToken).ConfigureAwait(false); } if (this._emitResult) { - await context.SendResultMessageAsync(this.Id).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 33cd8338112..377d65c99be 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -236,32 +236,29 @@ protected override void Visit(Question item) { this.Trace(item); - string parentId = GetParentId(item); - string actionId = item.GetId(); - string postId = Steps.Post(actionId); - // Entry point for question QuestionExecutor action = new(item, this._workflowState); this.ContinueWith(action); // Transition to post action if complete - this._workflowModel.AddLink(actionId, postId, QuestionExecutor.IsComplete); + string postId = Steps.Post(action.Id); + this._workflowModel.AddLink(action.Id, postId, QuestionExecutor.IsComplete); // Perpare for input request if not complete - string prepareId = QuestionExecutor.Steps.Prepare(actionId); - this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), parentId, message => !QuestionExecutor.IsComplete(message)); + string prepareId = QuestionExecutor.Steps.Prepare(action.Id); + this.ContinueWith(new DelegateActionExecutor(prepareId, this._workflowState, action.PrepareResponseAsync, emitResult: false), action.ParentId, message => !QuestionExecutor.IsComplete(message)); // Define input action - string inputId = QuestionExecutor.Steps.Input(actionId); + string inputId = QuestionExecutor.Steps.Input(action.Id); RequestPortAction inputPort = new(RequestPort.Create(inputId)); - this._workflowModel.AddNode(inputPort, parentId); - this._workflowModel.AddLinkFromPeer(parentId, inputId); + this._workflowModel.AddNode(inputPort, action.ParentId); + this._workflowModel.AddLinkFromPeer(action.ParentId, inputId); // Capture input response - string captureId = QuestionExecutor.Steps.Capture(actionId); - this.ContinueWith(new DelegateActionExecutor(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), parentId); + string captureId = QuestionExecutor.Steps.Capture(action.Id); + this.ContinueWith(new DelegateActionExecutor(captureId, this._workflowState, action.CaptureResponseAsync, emitResult: false), action.ParentId); // Transition to post action if complete - this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), parentId, QuestionExecutor.IsComplete); + this.ContinueWith(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId, QuestionExecutor.IsComplete); // Transition to prepare action if not complete this._workflowModel.AddLink(captureId, prepareId, message => !QuestionExecutor.IsComplete(message)); } @@ -313,7 +310,30 @@ protected override void Visit(InvokeAzureAgent item) { this.Trace(item); - this.ContinueWith(new InvokeAzureAgentExecutor(item, this._workflowOptions.AgentProvider, this._workflowState)); + // Entry point to invoke agent + InvokeAzureAgentExecutor action = new(item, this._workflowOptions.AgentProvider, this._workflowState); + this.ContinueWith(action); + // Transition to post action if complete + string postId = Steps.Post(action.Id); + this._workflowModel.AddLink(action.Id, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result)); + + // Define input action + string inputId = InvokeAzureAgentExecutor.Steps.Input(action.Id); + RequestPortAction inputPort = new(RequestPort.Create(inputId)); + this._workflowModel.AddNode(inputPort, action.ParentId); + this._workflowModel.AddLink(action.Id, inputId, InvokeAzureAgentExecutor.RequiresInput); + + // Input port always transitions to resume + string resumeId = InvokeAzureAgentExecutor.Steps.Resume(action.Id); + this._workflowModel.AddNode(new DelegateActionExecutor(resumeId, this._workflowState, action.ResumeAsync), action.ParentId); + this._workflowModel.AddLink(inputId, resumeId); + // Transition to request port if more input is required + this._workflowModel.AddLink(resumeId, inputId, InvokeAzureAgentExecutor.RequiresInput); + // Transition to post action if complete + this._workflowModel.AddLink(resumeId, postId, result => !InvokeAzureAgentExecutor.RequiresInput(result)); + + // Define post action + this._workflowModel.AddNode(new DelegateActionExecutor(postId, this._workflowState, action.CompleteAsync), action.ParentId); } protected override void Visit(RetrieveConversationMessage item) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs index e92c6b1b7d2..a73eb70a734 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutor.cs @@ -73,12 +73,12 @@ public ValueTask ResetAsync() } /// - public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context) + public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken) { object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._session.State), message, cancellationToken: default).ConfigureAwait(false); Debug.WriteLine($"RESULT #{this.Id} - {result ?? "(null)"}"); - await context.SendResultMessageAsync(this.Id, result).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, result, cancellationToken).ConfigureAwait(false); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs index 185068d550b..e33f32a1a3d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/IWorkflowContextExtensions.cs @@ -132,7 +132,7 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// The converted value public static async ValueTask ConvertValueAsync(this IWorkflowContext context, VariableType targetType, string key, string? scopeName = null, CancellationToken cancellationToken = default) { - object? sourceValue = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); + object? sourceValue = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); return sourceValue.ConvertType(targetType); } @@ -143,10 +143,11 @@ public static async ValueTask FormatTemplateAsync(this IWorkflowContext /// The workflow execution context used to restore persisted state prior to formatting. /// The key of the state value. /// An optional name that specifies the scope to read.If null, the default scope is used. + /// A token that propagates notification when operation should be canceled. /// The evaluated list expression - public static async ValueTask?> ReadListAsync(this IWorkflowContext context, string key, string? scopeName = null) + public static async ValueTask?> ReadListAsync(this IWorkflowContext context, string key, string? scopeName = null, CancellationToken cancellationToken = default) { - object? value = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); + object? value = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); return value.AsList(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs new file mode 100644 index 00000000000..ab9a196091b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/PortableValueExtensions.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.PowerFx.Types; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; + +/// +/// Extension helpers for converting instances (and collections containing them) +/// into their normalized runtime representations (primarily primitives) ready for evaluation. +/// +public static class PortableValueExtensions +{ + /// + /// Normalizes all values in the provided dictionary. Each entry whose value is a + /// is converted to its underlying normalized representation; non-PortableValue entries are preserved as-is. + /// + /// The source dictionary whose values may contain instances; may be null. + /// + /// A new dictionary with normalized values, or null if is null. + /// Keys are copied unchanged. + /// + public static IDictionary? NormalizePortableValues(this IDictionary? source) + { + if (source is null) + { + return null; + } + + return source.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.NormalizePortableValue()); + } + + /// + /// Normalizes an arbitrary value if it is a ; otherwise returns the value unchanged. + /// + /// The value to normalize; may be null or already a primitive/object. + /// + /// Null if is null; the normalized result if it is a ; + /// otherwise the original . + /// + public static object? NormalizePortableValue(this object? value) => + Throw.IfNull(value, nameof(value)) switch + { + null => null, + JsonElement jsonValue => jsonValue.GetValue(), + PortableValue portableValue => portableValue.Normalize(), + _ => value, + }; + + /// + /// Converts a into a concrete representation suitable for evaluation. + /// + /// The portable value to normalize; cannot be null. + /// + /// A instance representing the underlying value. + /// + public static object? Normalize(this PortableValue value) => + Throw.IfNull(value, nameof(value)).TypeId switch + { + _ when value.IsType(out string? stringValue) => stringValue, + _ when value.IsSystemType(out bool? boolValue) => boolValue.Value, + _ when value.IsSystemType(out int? intValue) => intValue.Value, + _ when value.IsSystemType(out long? longValue) => longValue.Value, + _ when value.IsSystemType(out decimal? decimalValue) => decimalValue.Value, + _ when value.IsSystemType(out float? floatValue) => floatValue.Value, + _ when value.IsSystemType(out double? doubleValue) => doubleValue.Value, + _ when value.IsParentType(out IDictionary? recordValue) => recordValue.NormalizePortableValues(), + _ when value.IsParentType(out IEnumerable? listValue) => listValue.NormalizePortableValues(), + _ => throw new DeclarativeActionException($"Unsupported portable type: {value.TypeId.TypeName}"), + }; + + private static Dictionary NormalizePortableValues(this IDictionary source) + { + return GetValues().ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + IEnumerable> GetValues() + { + foreach (DictionaryEntry entry in source) + { + yield return new KeyValuePair((string)entry.Key, entry.Value.NormalizePortableValue()); + } + } + } + + private static object?[] NormalizePortableValues(this IEnumerable source) => + source.Cast().Select(NormalizePortableValue).ToArray(); + + private static object? GetValue(this JsonElement element) => + element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Number => element.TryGetInt64(out long longValue) ? longValue : element.GetDouble(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetValue()), + JsonValueKind.Array => element.EnumerateArray().Select(e => e.GetValue()).ToArray(), + _ => throw new DeclarativeActionException($"Unsupported JSON value kind: {element.ValueKind}"), + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs index a1ebd09f5b1..44392077016 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/RootExecutor.cs @@ -6,7 +6,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; -using Microsoft.Bot.ObjectModel; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; @@ -55,23 +54,23 @@ public ValueTask ResetAsync() } /// - public override async ValueTask HandleAsync(TInput message, IWorkflowContext context) + public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { DeclarativeWorkflowContext declarativeContext = new(context, this._state); - await this.ExecuteAsync(message, declarativeContext, cancellationToken: default).ConfigureAwait(false); + await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false); ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message); if (string.IsNullOrWhiteSpace(this._conversationId)) { - this._conversationId = await this._agentProvider.CreateConversationAsync(cancellationToken: default).ConfigureAwait(false); + this._conversationId = await this._agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); } - await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true).ConfigureAwait(false); + await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false); - await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken: default).ConfigureAwait(false); - await declarativeContext.SetLastMessageAsync(input).ConfigureAwait(false); + ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false); + await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false); - await declarativeContext.SendMessageAsync(new ActionExecutorResult(this.Id)).ConfigureAwait(false); + await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } /// @@ -94,7 +93,7 @@ protected async ValueTask InitializeEnvironmentAsync(IWorkflowContext context, p { foreach (string variableName in variableNames) { - await context.QueueStateUpdateAsync(variableName, GetEnvironmentVariable(variableName), VariableScopeNames.Environment).ConfigureAwait(false); + await context.QueueEnvironmentUpdateAsync(variableName, GetEnvironmentVariable(variableName)).ConfigureAwait(false); } string GetEnvironmentVariable(string name) 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 6cac284f469..9e23c5f7270 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 @@ -1,4 +1,4 @@ - + $(ProjectsTargetFrameworks) @@ -21,16 +21,15 @@ - - - + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs index 419e112d0a1..62f0dbac75f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ClearAllVariablesExecutor.cs @@ -28,7 +28,7 @@ internal sealed class ClearAllVariablesExecutor(ClearAllVariables model, Workflo if (scope is not null) { - await context.QueueClearScopeAsync(scope).ConfigureAwait(false); + await context.QueueClearScopeAsync(scope, cancellationToken).ConfigureAwait(false); Debug.WriteLine( $""" STATE: {this.GetType().Name} [{this.Id}] diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs index 65a6510b180..b935b6e1859 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ConditionGroupExecutor.cs @@ -69,5 +69,5 @@ public bool IsElse(object? message) } public async ValueTask DoneAsync(IWorkflowContext context, ActionExecutorResult _, CancellationToken cancellationToken) => - await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs index 1da428f931e..e2290468641 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CreateConversationExecutor.cs @@ -17,7 +17,7 @@ internal sealed class CreateConversationExecutor(CreateConversation model, Workf { string conversationId = await agentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false); await this.AssignAsync(this.Model.ConversationId?.Path, FormulaValue.New(conversationId), context).ConfigureAwait(false); - await context.QueueConversationUpdateAsync(conversationId).ConfigureAwait(false); + await context.QueueConversationUpdateAsync(conversationId, cancellationToken).ConfigureAwait(false); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs index 41c9e7cb5a6..3130a29b858 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ForeachExecutor.cs @@ -68,11 +68,11 @@ public async ValueTask TakeNextAsync(IWorkflowContext context, object? _, Cancel { FormulaValue value = this._values[this._index]; - await context.QueueStateUpdateAsync(Throw.IfNull(this.Model.Value), value).ConfigureAwait(false); + await context.QueueStateUpdateAsync(Throw.IfNull(this.Model.Value), value, cancellationToken).ConfigureAwait(false); if (this.Model.Index is not null) { - await context.QueueStateUpdateAsync(this.Model.Index.Path, FormulaValue.New(this._index)).ConfigureAwait(false); + await context.QueueStateUpdateAsync(this.Model.Index.Path, FormulaValue.New(this._index), cancellationToken).ConfigureAwait(false); } this._index++; @@ -83,15 +83,15 @@ public async ValueTask ResetAsync(IWorkflowContext context, object? _, Cancellat { try { - await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value)).ConfigureAwait(false); + await context.QueueStateResetAsync(Throw.IfNull(this.Model.Value), cancellationToken).ConfigureAwait(false); if (this.Model.Index is not null) { - await context.QueueStateResetAsync(this.Model.Index).ConfigureAwait(false); + await context.QueueStateResetAsync(this.Model.Index, cancellationToken).ConfigureAwait(false); } } finally { - await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 9d0c2eb3d52..b26add9f81e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -1,10 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.Bot.ObjectModel.Abstractions; @@ -16,23 +19,67 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowAgentProvider agentProvider, WorkflowFormulaState state) : DeclarativeActionExecutor(model, state) { + public static class Steps + { + public static string Input(string id) => $"{id}_{nameof(Input)}"; + public static string Resume(string id) => $"{id}_{nameof(Resume)}"; + } + + // Input is requested by a message other than ActionExecutorResult. + public static bool RequiresInput(object? message) => message is not ActionExecutorResult; + private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}"); private AzureAgentInput? AgentInput => this.Model.Input; private AzureAgentOutput? AgentOutput => this.Model.Output; + protected override bool EmitResultEvent => false; + protected override bool IsDiscreteAction => false; + protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + await this.InvokeAgentAsync(context, this.GetInputMessages(), cancellationToken).ConfigureAwait(false); + + return default; + } + + public ValueTask ResumeAsync(IWorkflowContext context, AgentToolResponse message, CancellationToken cancellationToken) => + this.InvokeAgentAsync(context, [message.FunctionResults.ToChatMessage()], cancellationToken); + + public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) + { + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask InvokeAgentAsync(IWorkflowContext context, IEnumerable? messages, CancellationToken cancellationToken) { string? conversationId = this.GetConversationId(); string agentName = this.GetAgentName(); string? additionalInstructions = this.GetAdditionalInstructions(); bool autoSend = this.GetAutoSendValue(); - IEnumerable? inputMessages = this.GetInputMessages(); - AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, inputMessages, cancellationToken).ConfigureAwait(false); + bool isComplete = true; - await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); + AgentRunResponse agentResponse = await agentProvider.InvokeAgentAsync(this.Id, context, agentName, conversationId, autoSend, additionalInstructions, messages, cancellationToken).ConfigureAwait(false); - return default; + if (string.IsNullOrEmpty(agentResponse.Text)) + { + // Identify function calls that have no associated result. + List functionCalls = this.GetOrphanedFunctionCalls(agentResponse); + isComplete = functionCalls.Count == 0; + + if (!isComplete) + { + AgentToolRequest toolRequest = new(agentName, functionCalls); + await context.SendMessageAsync(toolRequest, targetId: null, cancellationToken).ConfigureAwait(false); + } + } + + if (isComplete) + { + await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + } + + await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); } private IEnumerable? GetInputMessages() @@ -48,6 +95,28 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, WorkflowA return userInput?.ToChatMessages(); } + private List GetOrphanedFunctionCalls(AgentRunResponse agentResponse) + { + HashSet functionResultIds = + [.. agentResponse.Messages + .SelectMany( + m => + m.Contents + .OfType() + .Select(functionCall => functionCall.CallId))]; + + List functionCalls = []; + foreach (FunctionCallContent functionCall in agentResponse.Messages.SelectMany(m => m.Contents.OfType())) + { + if (!functionResultIds.Contains(functionCall.CallId)) + { + functionCalls.Add(functionCall); + } + } + + return functionCalls; + } + private string? GetConversationId() { if (this.Model.ConversationId is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 1d34869441a..9dbaa2efa16 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -65,7 +65,7 @@ public static bool IsComplete(object? message) } else { - await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } return default; @@ -75,7 +75,7 @@ public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExec { int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false); InputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt)); - await context.SendMessageAsync(inputRequest).ConfigureAwait(false); + await context.SendMessageAsync(inputRequest, targetId: null, cancellationToken).ConfigureAwait(false); await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false); } @@ -85,7 +85,7 @@ public async ValueTask CaptureResponseAsync(IWorkflowContext context, InputRespo if (string.IsNullOrWhiteSpace(message.Value)) { string unrecognizedResponse = this.FormatPrompt(this.Model.UnrecognizedPrompt); - await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim())).ConfigureAwait(false); + await context.AddEventAsync(new MessageActivityEvent(unrecognizedResponse.Trim()), cancellationToken).ConfigureAwait(false); } else { @@ -97,7 +97,7 @@ public async ValueTask CaptureResponseAsync(IWorkflowContext context, InputRespo else { string invalidResponse = this.FormatPrompt(this.Model.InvalidPrompt); - await context.AddEventAsync(new MessageActivityEvent(invalidResponse.Trim())).ConfigureAwait(false); + await context.AddEventAsync(new MessageActivityEvent(invalidResponse.Trim()), cancellationToken).ConfigureAwait(false); } } @@ -109,13 +109,13 @@ public async ValueTask CaptureResponseAsync(IWorkflowContext context, InputRespo { await this.AssignAsync(this.Model.Variable?.Path, extractedValue, context).ConfigureAwait(false); await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false); - await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken) { - await context.RaiseCompletionEventAsync(this.Model).ConfigureAwait(false); + await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); } private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken cancellationToken) @@ -128,8 +128,8 @@ private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken DataValue defaultValue = this.Evaluator.GetValue(defaultValueExpression).Value; await this.AssignAsync(this.Model.Variable?.Path, defaultValue.ToFormula(), context).ConfigureAwait(false); string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse); - await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim())).ConfigureAwait(false); - await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); + await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false); + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } else { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs index da625b11ae0..eb679fa4b07 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/ResetVariableExecutor.cs @@ -17,7 +17,7 @@ internal sealed class ResetVariableExecutor(ResetVariable model, WorkflowFormula protected override async ValueTask ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Throw.IfNull(this.Model.Variable, $"{nameof(this.Model)}.{nameof(model.Variable)}"); - await context.QueueStateResetAsync(this.Model.Variable).ConfigureAwait(false); + await context.QueueStateResetAsync(this.Model.Variable, cancellationToken).ConfigureAwait(false); Debug.WriteLine( $""" STATE: {this.GetType().Name} [{this.Id}] diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs index 69ed8bb9706..9af463f8653 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/SendActivityExecutor.cs @@ -18,7 +18,7 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt { string activityText = this.Engine.Format(messageActivity.Text).Trim(); - await context.AddEventAsync(new MessageActivityEvent(activityText.Trim())).ConfigureAwait(false); + await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false); } return default; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index 17a013cb115..fa3ae6b32d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -274,6 +274,13 @@ private EvaluationResult EvaluateScope(ExpressionBase expression) expression.VariableReference?.ToString() : expression.ExpressionText; - return new(this._engine.Eval(expressionText), SensitivityLevel.None); + FormulaValue result = this._engine.Eval(expressionText); + + if (result is ErrorValue errorValue) + { + throw new DeclarativeActionException(errorValue.Format()); + } + + return new(result, SensitivityLevel.None); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs index ee58a3c63cd..82676ee93ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -2,11 +2,11 @@ using System.Collections.Frozen; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; -using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx; using Microsoft.PowerFx.Types; @@ -68,20 +68,25 @@ public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken return; } + Stopwatch timer = Stopwatch.StartNew(); + Debug.WriteLine("RESTORE CHECKPOINT - BEGIN"); await Task.WhenAll(RestorableScopes.Select(scopeName => ReadScopeAsync(scopeName))).ConfigureAwait(false); + Debug.WriteLine($"RESTORE CHECKPOINT - COMPLETE [{timer.Elapsed}]"); async Task ReadScopeAsync(string scopeName) { - HashSet keys = await context.ReadStateKeysAsync(scopeName).ConfigureAwait(false); + HashSet keys = await context.ReadStateKeysAsync(scopeName, cancellationToken).ConfigureAwait(false); foreach (string key in keys) { - object? value = await context.ReadStateAsync(key, scopeName).ConfigureAwait(false); - if (value is null or UnassignedValue) + PortableValue? value = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + if (value is null) { - value = FormulaValue.NewBlank(); + this.Set(key, FormulaValue.NewBlank(), scopeName); + continue; } - - this.Set(key, value.ToFormula(), scopeName); + FormulaValue formulaValue = value.ToFormula(); + this.Set(key, formulaValue, scopeName); + Debug.WriteLine($"RESTORED: {scopeName}.{key} => {formulaValue.Type}"); } this.Bind(scopeName); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs index 4245d828b21..9db57aba354 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/WorkflowAgentProvider.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Declarative; @@ -12,6 +13,49 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// public abstract class WorkflowAgentProvider { + /// + /// Gets or sets a collection of additional tools an agent is able to automatically invoke. + /// If an agent is configured with a function tool that is not available, a is executed + /// that provides an that describes the function calls requested. The caller may + /// then respond with a corrsponding that includes the results of the function calls. + /// + /// + /// These will not impact the requests sent to the model by the . + /// + public IEnumerable? Functions { get; init; } + + /// + /// Gets or sets a value indicating whether to allow concurrent invocation of functions. + /// + /// + /// if multiple function calls can execute in parallel. + /// if function calls are processed serially. + /// The default value is . + /// + /// + /// An individual response from the inner client might contain multiple function call requests. + /// By default, such function calls are processed serially. Set to + /// to enable concurrent invocation such that multiple function calls can execute in parallel. + /// + public bool AllowConcurrentInvocation { get; init; } + + /// + /// Gets or sets a flag to indicate whether a single response is allowed to include multiple tool calls. + /// If , the is asked to return a maximum of one tool call per request. + /// If , there is no limit. + /// If , the provider may select its own default. + /// + /// + /// + /// When used with function calling middleware, this does not affect the ability to perform multiple function calls in sequence. + /// It only affects the number of function calls within a single iteration of the function calling loop. + /// + /// + /// The underlying provider is not guaranteed to support or honor this flag. For example it may choose to ignore it and return multiple tool calls regardless. + /// + /// + public bool AllowMultipleToolCalls { get; init; } + /// /// Asynchronously retrieves an AI agent by its unique identifier. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs new file mode 100644 index 00000000000..b255a499eff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.RegularExpressions; + +namespace Microsoft.Agents.AI.Workflows; + +internal static partial class AIAgentExtensions +{ + /// + /// Derives from an agent a unique but also hopefully descriptive name that can be used as an executor's + /// name or in a function name. + /// + public static string GetDescriptiveId(this AIAgent agent) + { + string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}"; + return InvalidNameCharsRegex().Replace(id, "_"); + } + + /// + /// Regex that flags any character other than ASCII digits or letters or the underscore. + /// +#if NET + [GeneratedRegex("[^0-9A-Za-z]+")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; + private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled); +#endif +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs new file mode 100644 index 00000000000..ec557d0c532 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentIDEqualityComparer.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Agents.AI.Workflows; + +internal sealed class AIAgentIDEqualityComparer : IEqualityComparer +{ + public static AIAgentIDEqualityComparer Instance { get; } = new(); + public bool Equals(AIAgent? x, AIAgent? y) => x?.Id == y?.Id; + public int GetHashCode([DisallowNull] AIAgent obj) => obj?.GetHashCode() ?? 0; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs index 3d53d37f781..9f9906270e2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows; @@ -16,4 +18,41 @@ public static ChatMessage ToChatMessage(this AgentRunResponseUpdate update) => MessageId = update.MessageId, RawRepresentation = update.RawRepresentation ?? update, }; + + /// + /// Iterates through looking for messages and swapping + /// any that have a different from to + /// . + /// + public static List? ChangeAssistantToUserForOtherParticipants(this List messages, string targetAgentName) + { + List? roleChanged = null; + foreach (var m in messages) + { + if (m.Role == ChatRole.Assistant && + m.AuthorName != targetAgentName && + m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent)) + { + m.Role = ChatRole.User; + (roleChanged ??= []).Add(m); + } + } + + return roleChanged; + } + + /// + /// Undoes changes made by when passed the list of changes + /// made by that method. + /// + public static void ResetUserToAssistantForChangedRoles(this List? roleChanged) + { + if (roleChanged is not null) + { + foreach (var m in roleChanged) + { + m.Role = ChatRole.Assistant; + } + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs index b21edee04cf..907d10fe603 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AgentWorkflowBuilder.cs @@ -2,14 +2,9 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -26,6 +21,18 @@ public static partial class AgentWorkflowBuilder /// The sequence of agents to compose into a sequential workflow. /// The built workflow composed of the supplied , in the order in which they were yielded from the source. public static Workflow BuildSequential(params IEnumerable agents) + => BuildSequentialCore(workflowName: null, agents); + + /// + /// Builds a composed of a pipeline of agents where the output of one agent is the input to the next. + /// + /// The name of workflow. + /// The sequence of agents to compose into a sequential workflow. + /// The built workflow composed of the supplied , in the order in which they were yielded from the source. + public static Workflow BuildSequential(string workflowName, params IEnumerable agents) + => BuildSequentialCore(workflowName, agents); + + private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable agents) { Throw.IfNull(agents); @@ -60,9 +67,12 @@ public static Workflow BuildSequential(params IEnumerable agents) Debug.Assert(builder is not null); OutputMessagesExecutor end = new(); - return builder.AddEdge(previous, end) - .WithOutputFrom(end) - .Build(); + builder = builder.AddEdge(previous, end).WithOutputFrom(end); + if (workflowName is not null) + { + builder = builder.WithName(workflowName); + } + return builder.Build(); } /// @@ -79,6 +89,30 @@ public static Workflow BuildSequential(params IEnumerable agents) public static Workflow BuildConcurrent( IEnumerable agents, Func>, List>? aggregator = null) + => BuildConcurrentCore(workflowName: null, agents, aggregator); + + /// + /// Builds a composed of agents that operate concurrently on the same input, + /// aggregating their outputs into a single collection. + /// + /// The name of the workflow. + /// The set of agents to compose into a concurrent workflow. + /// + /// The aggregation function that accepts a list of the output messages from each and produces + /// a single result list. If , the default behavior is to return a list containing the last message + /// from each agent that produced at least one message. + /// + /// The built workflow composed of the supplied concurrent . + public static Workflow BuildConcurrent( + string workflowName, + IEnumerable agents, + Func>, List>? aggregator = null) + => BuildConcurrentCore(workflowName, agents, aggregator); + + private static Workflow BuildConcurrentCore( + string? workflowName, + IEnumerable agents, + Func>, List>? aggregator = null) { Throw.IfNull(agents); @@ -91,7 +125,7 @@ public static Workflow BuildConcurrent( // accumulator would not be able to determine what came from what agent, as there's currently no // provenance tracking exposed in the workflow context passed to a handler. ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray(); - ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new BatchChatMessagesToListExecutor($"Batcher/{agent.Id}")]; + ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")]; builder.AddFanOutEdge(start, targets: agentExecutors); for (int i = 0; i < agentExecutors.Length; i++) { @@ -105,7 +139,12 @@ public static Workflow BuildConcurrent( ConcurrentEndExecutor end = new(agentExecutors.Length, aggregator); builder.AddFanInEdge(end, sources: accumulators); - return builder.WithOutputFrom(end).Build(); + builder = builder.WithOutputFrom(end); + if (workflowName is not null) + { + builder = builder.WithName(workflowName); + } + return builder.Build(); } /// Creates a new using as the starting agent in the workflow. @@ -140,752 +179,4 @@ public static GroupChatWorkflowBuilder CreateGroupChatBuilderWith(Func - /// Executor that runs the agent and forwards all messages, input and output, to the next executor. - /// - private sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor - { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context) => - { - List messages = [.. this._pendingMessages]; - this._pendingMessages.Clear(); - - List? roleChanged = ChangeAssistantToUserForOtherParticipants(agent.DisplayName, messages); - - List updates = []; - await foreach (var update in agent.RunStreamingAsync(messages).ConfigureAwait(false)) - { - updates.Add(update); - if (token.EmitEvents is true) - { - await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); - } - } - - ResetUserToAssistantForChangedRoles(roleChanged); - - if (!includeInputInOutput) - { - messages.Clear(); - } - - messages.AddRange(updates.ToAgentRunResponse().Messages); - - await context.SendMessageAsync(messages).ConfigureAwait(false); - await context.SendMessageAsync(token).ConfigureAwait(false); - }); - - public ValueTask ResetAsync() - { - this._pendingMessages.Clear(); - return default; - } - } - - /// - /// Provides an executor that batches received chat messages that it then publishes as the final result - /// when receiving a . - /// - private sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor - { - protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.YieldOutputAsync(messages); - - ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); - } - - /// Executor that forwards all messages. - private sealed class ChatForwardingExecutor(string id) : Executor(id), IResettableExecutor - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))) - .AddHandler((message, context) => context.SendMessageAsync(message)) - .AddHandler>((messages, context) => context.SendMessageAsync(messages)) - .AddHandler((turnToken, context) => context.SendMessageAsync(turnToken)); - - public ValueTask ResetAsync() => default; - } - - /// - /// Provides an executor that batches received chat messages that it then releases when - /// receiving a . - /// - private sealed class BatchChatMessagesToListExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor - { - protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) - => context.SendMessageAsync(messages); - - ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); - } - - /// - /// Provides an executor that accepts the output messages from each of the concurrent agents - /// and produces a result list containing the last message from each. - /// - private sealed class ConcurrentEndExecutor : Executor, IResettableExecutor - { - private readonly int _expectedInputs; - private readonly Func>, List> _aggregator; - private List> _allResults; - private int _remaining; - - public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) : base("ConcurrentEnd") - { - this._expectedInputs = expectedInputs; - this._aggregator = Throw.IfNull(aggregator); - - this._allResults = new List>(expectedInputs); - this._remaining = expectedInputs; - } - - private void Reset() - { - this._allResults = new List>(this._expectedInputs); - this._remaining = this._expectedInputs; - } - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler>(async (messages, context) => - { - // TODO: https://github.com/microsoft/agent-framework/issues/784 - // This locking should not be necessary. - bool done; - lock (this._allResults) - { - this._allResults.Add(messages); - done = --this._remaining == 0; - } - - if (done) - { - this._remaining = this._expectedInputs; - - var results = this._allResults; - this._allResults = new List>(this._expectedInputs); - await context.YieldOutputAsync(this._aggregator(results)).ConfigureAwait(false); - } - }); - - public ValueTask ResetAsync() - { - this.Reset(); - return default; - } - } - - /// - /// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. - /// - public sealed class HandoffsWorkflowBuilder - { - private const string FunctionPrefix = "handoff_to_"; - private readonly AIAgent _initialAgent; - private readonly Dictionary> _targets = []; - private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); - - /// - /// Initializes a new instance of the class with no handoff relationships. - /// - /// The first agent to be invoked (prior to any handoff). - internal HandoffsWorkflowBuilder(AIAgent initialAgent) - { - this._initialAgent = initialAgent; - this._allAgents.Add(initialAgent); - } - - /// - /// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them. - /// - /// - /// By default, simple instructions are included. This may be set to to avoid including - /// any additional instructions, or may be customized to provide more specific guidance. - /// - public string? HandoffInstructions { get; set; } = - $""" - You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved - by calling a handoff function, named in the form `{FunctionPrefix}`; the description of the function provides details on the - target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs - in your conversation with the user. - """; - - /// - /// Adds handoff relationships from a source agent to one or more target agents. - /// - /// The source agent. - /// The target agents to add as handoff targets for the source agent. - /// The updated instance. - /// The handoff reason for each target in is derived from that agent's description or name. - public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) - { - Throw.IfNull(from); - Throw.IfNull(to); - - foreach (var target in to) - { - if (target is null) - { - Throw.ArgumentNullException(nameof(to), "One or more target agents are null."); - } - - this.WithHandoff(from, target); - } - - return this; - } - - /// - /// Adds handoff relationships from one or more sources agent to a target agent. - /// - /// The source agents. - /// The target agent to add as a handoff target for each source agent. - /// - /// The reason the should hand off to the . - /// If , the reason is derived from 's description or name. - /// - /// The updated instance. - public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) - { - Throw.IfNull(from); - Throw.IfNull(to); - - foreach (var source in from) - { - if (source is null) - { - Throw.ArgumentNullException(nameof(from), "One or more source agents are null."); - } - - this.WithHandoff(source, to, handoffReason); - } - - return this; - } - - /// - /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. - /// - /// The source agent. - /// The target agent. - /// - /// The reason the should hand off to the . - /// If , the reason is derived from 's description or name. - /// - /// The updated instance. - public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) - { - Throw.IfNull(from); - Throw.IfNull(to); - - this._allAgents.Add(from); - this._allAgents.Add(to); - - if (!this._targets.TryGetValue(from, out var handoffs)) - { - this._targets[from] = handoffs = []; - } - - if (string.IsNullOrWhiteSpace(handoffReason)) - { - handoffReason = to.Description ?? to.Name ?? (to as ChatClientAgent)?.Instructions; - if (string.IsNullOrWhiteSpace(handoffReason)) - { - Throw.ArgumentException( - nameof(to), - $"The provided target agent '{to.DisplayName}' has no description, name, or instructions, and no handoff description has been provided. " + - "At least one of these is required to register a handoff so that the appropriate target agent can be chosen."); - } - } - - if (!handoffs.Add(new(to, handoffReason))) - { - Throw.InvalidOperationException($"A handoff from agent '{from.DisplayName}' to agent '{to.DisplayName}' has already been registered."); - } - - return this; - } - - /// - /// Builds a composed of agents that operate via handoffs, with the next - /// agent to process messages selected by the current agent. - /// - /// The workflow built based on the handoffs in the builder. - public Workflow Build() - { - StartHandoffsExecutor start = new(); - EndHandoffsExecutor end = new(); - WorkflowBuilder builder = new(start); - - // Create an AgentExecutor for each again. - Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions)); - - // Connect the start executor to the initial agent. - builder.AddEdge(start, executors[this._initialAgent.Id]); - - // Initialize each executor with its handoff targets to the other executors. - foreach (var agent in this._allAgents) - { - executors[agent.Id].Initialize(builder, end, executors, - this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); - } - - // Build the workflow. - return builder.WithOutputFrom(end).Build(); - } - - /// Describes a handoff to a specific target . - private readonly record struct HandoffTarget(AIAgent Target, string? Reason = null) - { - public bool Equals(HandoffTarget other) => this.Target.Id == other.Target.Id; - public override int GetHashCode() => this.Target.Id.GetHashCode(); - } - - /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. - private sealed class StartHandoffsExecutor() : Executor("HandoffStart"), IResettableExecutor - { - private readonly List _pendingMessages = []; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context) => - { - var messages = new List(this._pendingMessages); - this._pendingMessages.Clear(); - await context.SendMessageAsync(new HandoffState(token, null, messages)).ConfigureAwait(false); - }); - - public ValueTask ResetAsync() - { - this._pendingMessages.Clear(); - return default; - } - } - - /// Executor used at the end of a handoff workflow to raise a final completed event. - private sealed class EndHandoffsExecutor() : Executor("HandoffEnd"), IResettableExecutor - { - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler((handoff, context) => - context.YieldOutputAsync(handoff.Messages)); - - public ValueTask ResetAsync() => default; - } - - /// Executor used to represent an agent in a handoffs workflow, responding to events. - private sealed class HandoffAgentExecutor( - AIAgent agent, - string? handoffInstructions) : Executor(GetDescriptiveIdFromAgent(agent)), IResettableExecutor - { - private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( - ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; - - private readonly AIAgent _agent = agent; - private readonly HashSet _handoffFunctionNames = []; - private ChatClientAgentRunOptions? _agentOptions; - - public void Initialize( - WorkflowBuilder builder, - Executor end, - Dictionary executors, - HashSet handoffs) => - builder.AddSwitch(this, sb => - { - if (handoffs.Count != 0) - { - Debug.Assert(this._agentOptions is null); - this._agentOptions = new() - { - ChatOptions = new() - { - AllowMultipleToolCalls = false, - Instructions = handoffInstructions, - Tools = [], - }, - }; - - foreach (HandoffTarget handoff in handoffs) - { - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{FunctionPrefix}{GetDescriptiveIdFromAgent(handoff.Target)}", handoff.Reason, s_handoffSchema); - - this._handoffFunctionNames.Add(handoffFunc.Name); - - this._agentOptions.ChatOptions.Tools.Add(handoffFunc); - - sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); - } - } - - sb.WithDefault(end); - }); - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => - routeBuilder.AddHandler(async (handoffState, context) => - { - string? requestedHandoff = null; - List updates = []; - List allMessages = handoffState.Messages; - - List? roleChanges = ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName, allMessages); - - await foreach (var update in this._agent.RunStreamingAsync(allMessages, options: this._agentOptions).ConfigureAwait(false)) - { - await AddUpdateAsync(update).ConfigureAwait(false); - - foreach (var c in update.Contents) - { - if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name)) - { - requestedHandoff = fcc.Name; - await AddUpdateAsync(new AgentRunResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.DisplayName, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }).ConfigureAwait(false); - } - } - } - - allMessages.AddRange(updates.ToAgentRunResponse().Messages); - - ResetUserToAssistantForChangedRoles(roleChanges); - - await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages)).ConfigureAwait(false); - - async Task AddUpdateAsync(AgentRunResponseUpdate update) - { - updates.Add(update); - if (handoffState.TurnToken.EmitEvents is true) - { - await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); - } - } - }); - - public ValueTask ResetAsync() => default; - } - - private sealed record class HandoffState( - TurnToken TurnToken, - string? InvokedHandoff, - List Messages); - } - - /// - /// A manager that manages the flow of a group chat. - /// - public abstract class GroupChatManager - { - private int _maximumIterationCount = 40; - - /// - /// Initializes a new instance of the class. - /// - protected GroupChatManager() { } - - /// - /// Gets the number of iterations in the group chat so far. - /// - public int IterationCount { get; internal set; } - - /// - /// Gets or sets the maximum number of iterations allowed. - /// - /// - /// Each iteration involves a single interaction with a participating agent. - /// The default is 40. - /// - public int MaximumIterationCount - { - get => this._maximumIterationCount; - set => this._maximumIterationCount = Throw.IfLessThan(value, 1); - } - - /// - /// Selects the next agent to participate in the group chat based on the provided chat history and team. - /// - /// The chat history to consider. - /// The to monitor for cancellation requests. The default is . - /// The next to speak. This agent must be part of the chat. - protected internal abstract ValueTask SelectNextAgentAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default); - - /// - /// Filters the chat history before it's passed to the next agent. - /// - /// The chat history to filter. - /// The to monitor for cancellation requests. The default is . - /// The filtered chat history. - protected internal virtual ValueTask> UpdateHistoryAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default) => - new(history); - - /// - /// Determines whether the group chat should be terminated based on the provided chat history and iteration count. - /// - /// The chat history to consider. - /// The to monitor for cancellation requests. The default is . - /// A indicating whether the chat should be terminated. - protected internal virtual ValueTask ShouldTerminateAsync( - IReadOnlyList history, - CancellationToken cancellationToken = default) => - new(this.MaximumIterationCount is int max && this.IterationCount >= max); - - /// - /// Resets the state of the manager for a new group chat session. - /// - protected internal virtual void Reset() - { - this.IterationCount = 0; - } - } - - /// - /// Provides a that selects agents in a round-robin fashion. - /// - public class RoundRobinGroupChatManager : GroupChatManager - { - private readonly IReadOnlyList _agents; - private readonly Func, CancellationToken, ValueTask>? _shouldTerminateFunc; - private int _nextIndex; - - /// - /// Initializes a new instance of the class. - /// - /// The agents to be managed as part of this workflow. - /// - /// An optional function that determines whether the group chat should terminate based on the chat history - /// before factoring in the default behavior, which is to terminate based only on the iteration count. - /// - public RoundRobinGroupChatManager( - IReadOnlyList agents, - Func, CancellationToken, ValueTask>? shouldTerminateFunc = null) - { - Throw.IfNullOrEmpty(agents); - foreach (var agent in agents) - { - Throw.IfNull(agent, nameof(agents)); - } - - this._agents = agents; - this._shouldTerminateFunc = shouldTerminateFunc; - } - - /// - protected internal override ValueTask SelectNextAgentAsync( - IReadOnlyList history, CancellationToken cancellationToken = default) - { - AIAgent nextAgent = this._agents[this._nextIndex]; - - this._nextIndex = (this._nextIndex + 1) % this._agents.Count; - - return new ValueTask(nextAgent); - } - - /// - protected internal override async ValueTask ShouldTerminateAsync( - IReadOnlyList history, CancellationToken cancellationToken = default) - { - if (this._shouldTerminateFunc is { } func && await func(this, history, cancellationToken).ConfigureAwait(false)) - { - return true; - } - - return await base.ShouldTerminateAsync(history, cancellationToken).ConfigureAwait(false); - } - - /// - protected internal override void Reset() - { - base.Reset(); - this._nextIndex = 0; - } - } - - /// - /// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. - /// - public sealed class GroupChatWorkflowBuilder - { - private readonly Func, GroupChatManager> _managerFactory; - private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); - - internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => - this._managerFactory = managerFactory; - - /// - /// Adds the specified as participants to the group chat workflow. - /// - /// The agents to add as participants. - /// This instance of the . - public GroupChatWorkflowBuilder AddParticipants(params IEnumerable agents) - { - Throw.IfNull(agents); - - foreach (var agent in agents) - { - if (agent is null) - { - Throw.ArgumentNullException(nameof(agents), "One or more target agents are null."); - } - - this._participants.Add(agent); - } - - return this; - } - - /// - /// Builds a composed of agents that operate via group chat, with the next - /// agent to process messages selected by the group chat manager. - /// - /// The workflow built based on the group chat in the builder. - public Workflow Build() - { - AIAgent[] agents = this._participants.ToArray(); - Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); - - GroupChatHost host = new(agents, agentMap, this._managerFactory); - - WorkflowBuilder builder = new(host); - - foreach (var participant in agentMap.Values) - { - builder - .AddEdge(host, participant) - .AddEdge(participant, host); - } - - return builder.WithOutputFrom(host).Build(); - } - - private sealed class GroupChatHost(AIAgent[] agents, Dictionary agentMap, Func, GroupChatManager> managerFactory) : Executor("GroupChatHost"), IResettableExecutor - { - private readonly AIAgent[] _agents = agents; - private readonly Dictionary _agentMap = agentMap; - private readonly Func, GroupChatManager> _managerFactory = managerFactory; - private readonly List _pendingMessages = []; - - private GroupChatManager? _manager; - - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder - .AddHandler((message, context) => this._pendingMessages.Add(new(ChatRole.User, message))) - .AddHandler((message, context) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) - .AddHandler((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed - .AddHandler(async (token, context) => - { - List messages = [.. this._pendingMessages]; - this._pendingMessages.Clear(); - - this._manager ??= this._managerFactory(this._agents); - - if (!await this._manager.ShouldTerminateAsync(messages).ConfigureAwait(false)) - { - var filtered = await this._manager.UpdateHistoryAsync(messages).ConfigureAwait(false); - messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered]; - - if (await this._manager.SelectNextAgentAsync(messages).ConfigureAwait(false) is AIAgent nextAgent && - this._agentMap.TryGetValue(nextAgent, out var executor)) - { - this._manager.IterationCount++; - await context.SendMessageAsync(messages, executor.Id).ConfigureAwait(false); - await context.SendMessageAsync(token, executor.Id).ConfigureAwait(false); - return; - } - } - - this._manager = null; - await context.YieldOutputAsync(messages).ConfigureAwait(false); - }); - - public ValueTask ResetAsync() - { - this._pendingMessages.Clear(); - this._manager = null; - - return default; - } - } - } - - /// - /// Iterates through looking for messages and swapping - /// any that have a different from to . - /// - private static List? ChangeAssistantToUserForOtherParticipants(string targetAgentName, List messages) - { - List? roleChanged = null; - foreach (var m in messages) - { - if (m.Role == ChatRole.Assistant && - m.AuthorName != targetAgentName && - m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent)) - { - m.Role = ChatRole.User; - (roleChanged ??= []).Add(m); - } - } - - return roleChanged; - } - - /// - /// Undoes changes made by - /// when passed the list of changes made by that method. - /// - private static void ResetUserToAssistantForChangedRoles(List? roleChanged) - { - if (roleChanged is not null) - { - foreach (var m in roleChanged) - { - m.Role = ChatRole.Assistant; - } - } - } - - /// Derives from an agent a unique but also hopefully descriptive name that can be used as an executor's name or in a function name. - private static string GetDescriptiveIdFromAgent(AIAgent agent) - { - string id = string.IsNullOrEmpty(agent.Name) ? agent.Id : $"{agent.Name}_{agent.Id}"; - return InvalidNameCharsRegex().Replace(id, "_"); - } - - /// Regex that flags any character other than ASCII digits or letters or the underscore. -#if NET - [GeneratedRegex("[^0-9A-Za-z_]+")] - private static partial Regex InvalidNameCharsRegex(); -#else - private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; - private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z_]+", RegexOptions.Compiled); -#endif - - private sealed class AIAgentIDEqualityComparer : IEqualityComparer - { - public static AIAgentIDEqualityComparer Instance { get; } = new(); - public bool Equals(AIAgent? x, AIAgent? y) => x?.Id == y?.Id; - public int GetHashCode([DisallowNull] AIAgent obj) => obj?.GetHashCode() ?? 0; - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs index c6cb36abe3f..5aa31513a58 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AggregatingExecutor.cs @@ -19,34 +19,39 @@ namespace Microsoft.Agents.AI.Workflows; /// function receives the current aggregate (or null if this is the first message) and the input message, and returns /// the updated aggregate. /// Optional configuration settings for the executor. If null, default options are used. +/// Declare that this executor may be used simultaneously by multiple runs safely. /// public class AggregatingExecutor(string id, Func aggregator, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { private const string AggregateStateKey = "Aggregate"; - private TAggregate? _runningAggregate; /// - public override ValueTask HandleAsync(TInput message, IWorkflowContext context) + public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { - this._runningAggregate = aggregator(this._runningAggregate, message); - return new(this._runningAggregate); - } + TAggregate? runningAggregate = default; + await context.InvokeWithStateAsync(InvokeAggregatorAsync, AggregateStateKey, cancellationToken: cancellationToken) + .ConfigureAwait(false); - /// - protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - await context.QueueStateUpdateAsync(AggregateStateKey, this._runningAggregate).ConfigureAwait(false); + return runningAggregate; - await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); - } + ValueTask InvokeAggregatorAsync(PortableValue? maybeState, IWorkflowContext context, CancellationToken cancellationToken) + { + if (maybeState == null || !maybeState.Is(out runningAggregate)) + { + runningAggregate = default; + } - /// - protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + runningAggregate = aggregator(runningAggregate, message); + + if (runningAggregate == null) + { + return new((PortableValue?)null); + } - this._runningAggregate = await context.ReadStateAsync(AggregateStateKey).ConfigureAwait(false); + return new(new PortableValue(runningAggregate)); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs deleted file mode 100644 index 99f8a1fa628..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncBarrier.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.Execution; - -namespace Microsoft.Agents.AI.Workflows; - -internal sealed class AsyncBarrier() -{ - private readonly InitLocked> _completionSource = new(); - - public async ValueTask JoinAsync(CancellationToken cancellation = default) - { - this._completionSource.Init(() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); - TaskCompletionSource completionSource = this._completionSource.Get()!; - - // Create a new completion source to track cancellation, because cancelling a single waiter's join - // should not cancel the entire barrier. - TaskCompletionSource cancellationSource = new(); - - using CancellationTokenRegistration registration = cancellation.Register(() => cancellationSource.SetResult(new())); - - await Task.WhenAny(completionSource.Task, cancellationSource.Task).ConfigureAwait(false); - return !cancellation.IsCancellationRequested; - } - - public bool ReleaseBarrier() - { - // If there is no completion source, then there are no waiters. - return this._completionSource.Get()?.TrySetResult(new()) ?? false; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs deleted file mode 100644 index 835a78ab2ad..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AsyncCoordinator.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Workflows; - -internal sealed class AsyncCoordinator -{ - private AsyncBarrier? _coordinationBarrier; - - /// - /// Wait for the Coordination owner to mark the next coordination point, then continue execution. - /// - /// A cancellation token that can be used to cancel the wait. - /// - /// A task that represents the asynchronous operation. The task result is - /// if the wait was completed; otherwise, for example, if the wait was cancelled, . - /// - public async ValueTask WaitForCoordinationAsync(CancellationToken cancellation = default) - { - // There is a chance that we might get a stale barrier that is getting released if there is a - // release happening concurrently with this call. This is by design, and should be considered - // when using this class. - AsyncBarrier actualBarrier = this._coordinationBarrier - ?? Interlocked.CompareExchange(ref this._coordinationBarrier, new(), null) - ?? this._coordinationBarrier!; // Re-read after setting - - return await actualBarrier.JoinAsync(cancellation).ConfigureAwait(false); - } - - /// - /// Marks the coordination point and releases any waiting operations if a coordination barrier is present. - /// - /// true if a coordination barrier was released; otherwise, false. - public bool MarkCoordinationPoint() - { - AsyncBarrier? maybeBarrier = Interlocked.Exchange(ref this._coordinationBarrier, null); - return maybeBarrier?.ReleaseBarrier() ?? false; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs index afcc7e52cc4..480c2e0ce3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatProtocolExecutor.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -13,60 +13,73 @@ internal class ChatProtocolExecutorOptions public ChatRole? StringMessageChatRole { get; set; } } -internal abstract class ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null) : Executor(id) +// TODO: Make this a public type (in a later PR; todo: make an issue) +internal abstract class ChatProtocolExecutor : StatefulExecutor> { - private List _pendingMessages = []; - private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole; + private readonly static Func> s_initFunction = () => []; + private readonly ChatRole? _stringMessageChatRole; - // Note that we explicitly do not implement IResettableExecutor here, as we want to allow derived classes to - // implement it if they want to be resettable, but do not want to opt them into it. - protected ValueTask ResetAsync() + internal ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false) + : base(id, () => [], declareCrossRunShareable: declareCrossRunShareable) { - this._pendingMessages = []; - return default; + this._stringMessageChatRole = options?.StringMessageChatRole; } protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { if (this._stringMessageChatRole.HasValue) { - routeBuilder = routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(new(this._stringMessageChatRole.Value, message))); + routeBuilder = routeBuilder.AddHandler( + (message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context)); } - return routeBuilder.AddHandler((message, _) => this._pendingMessages.Add(message)) - .AddHandler>((messages, _) => this._pendingMessages.AddRange(messages)) + return routeBuilder.AddHandler(this.AddMessageAsync) + .AddHandler>(this.AddMessagesAsync) + .AddHandler(this.AddMessagesAsync) + .AddHandler>(this.AddMessagesAsync) .AddHandler(this.TakeTurnAsync); } - public async ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context) + protected ValueTask AddMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default) { - await this.TakeTurnAsync(this._pendingMessages, context, token.EmitEvents).ConfigureAwait(false); - this._pendingMessages = []; - await context.SendMessageAsync(token).ConfigureAwait(false); - } + return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken); - protected abstract ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default); + ValueTask?> ForwardMessageAsync(List? maybePendingMessages, IWorkflowContext context, CancellationToken cancelationToken) + { + maybePendingMessages ??= s_initFunction(); + maybePendingMessages.Add(message); + return new(maybePendingMessages); + } + } - private const string PendingMessagesStateKey = nameof(_pendingMessages); - protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + protected ValueTask AddMessagesAsync(IEnumerable messages, IWorkflowContext context, CancellationToken cancellationToken = default) { - Task messagesTask = Task.CompletedTask; - if (this._pendingMessages.Count > 0) + return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken); + + ValueTask?> ForwardMessageAsync(List? maybePendingMessages, IWorkflowContext context, CancellationToken cancelationToken) { - JsonElement messagesValue = this._pendingMessages.Serialize(); - messagesTask = context.QueueStateUpdateAsync(PendingMessagesStateKey, messagesValue).AsTask(); + maybePendingMessages ??= s_initFunction(); + maybePendingMessages.AddRange(messages); + return new(maybePendingMessages); } - - await messagesTask.ConfigureAwait(false); } - protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + public ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken = default) { - JsonElement? messagesValue = await context.ReadStateAsync(PendingMessagesStateKey).ConfigureAwait(false); - if (messagesValue.HasValue) + return this.InvokeWithStateAsync(InvokeTakeTurnAsync, context, cancellationToken: cancellationToken); + + async ValueTask?> InvokeTakeTurnAsync(List? maybePendingMessages, IWorkflowContext context, CancellationToken cancellationToken) { - List messages = messagesValue.Value.DeserializeMessages(); - this._pendingMessages.AddRange(messages); + await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken) + .ConfigureAwait(false); + + await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false); + + // Rerun the initialStateFactory to reset the state to empty list. (We could return the empty list directly, + // but this is more consistent if the initial state factory becomes more complex.) + return s_initFunction(); } } + + protected abstract ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs index 9334158b5be..c50283e728b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/CheckpointManager.cs @@ -20,7 +20,7 @@ private static CheckpointManagerImpl CreateImpl( return new CheckpointManagerImpl(marshaller, store); } - private CheckpointManager(ICheckpointManager impl) + internal CheckpointManager(ICheckpointManager impl) { this._impl = impl; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index 35f4b2d3767..2a9fbead28e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -56,7 +56,12 @@ public FileSystemJsonCheckpointStore(DirectoryInfo directory) { // read the lines of indexfile and parse them as CheckpointInfos this.CheckpointIndex = []; - using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: -1, leaveOpen: true); +#if NET + const int BufferSize = -1; +#else + const int BufferSize = 1024; +#endif + using StreamReader reader = new(this._indexFile, encoding: Encoding.UTF8, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true); while (reader.ReadLine() is string line) { if (JsonSerializer.Deserialize(line, KeyTypeInfo) is { } info) @@ -65,9 +70,9 @@ public FileSystemJsonCheckpointStore(DirectoryInfo directory) } } } - catch + catch (Exception exception) { - throw new InvalidOperationException($"Could not load store at '{directory.FullName}'. Index corrupted."); + throw new InvalidOperationException($"Could not load store at '{directory.FullName}'. Index corrupted.", exception); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs index 8fe6edce2a0..de1a1dfc7f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandle.cs @@ -44,19 +44,14 @@ internal AsyncRunHandle(ISuperStepRunner stepRunner, ICheckpointingHandle checkp } } - //private readonly AsyncCoordinator _waitForResponseCoordinator = new(); - - //public ValueTask WaitForNextInputAsync(CancellationToken cancellation = default) - // => this._waitForResponseCoordinator.WaitForCoordinationAsync(cancellation); - public string RunId => this._stepRunner.RunId; public IReadOnlyList Checkpoints => this._checkpointingHandle.Checkpoints; - public ValueTask GetStatusAsync(CancellationToken cancellation = default) - => this._eventStream.GetStatusAsync(cancellation); + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) + => this._eventStream.GetStatusAsync(cancellationToken); - public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellation = default) + public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) { //Debug.Assert(breakOnHalt); // Enforce single active enumerator (this runs when enumeration begins) @@ -68,7 +63,7 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe CancellationTokenSource? linked = null; try { - linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation, this._endRunSource.Token); + linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, this._endRunSource.Token); var token = linked.Token; // Build the inner stream before the loop so synchronous exceptions still release the gate @@ -92,21 +87,21 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe } } - public ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default) - => this._stepRunner.IsValidInputTypeAsync(cancellation); + public ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default) + => this._stepRunner.IsValidInputTypeAsync(cancellationToken); - public async ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default) + public async ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default) { if (message is ExternalResponse response) { // EnqueueResponseAsync handles signaling - await this.EnqueueResponseAsync(response, cancellation) + await this.EnqueueResponseAsync(response, cancellationToken) .ConfigureAwait(false); return true; } - bool result = await this._stepRunner.EnqueueMessageAsync(message, cancellation) + bool result = await this._stepRunner.EnqueueMessageAsync(message, cancellationToken) .ConfigureAwait(false); // Signal the run loop that new input is available @@ -115,7 +110,7 @@ await this.EnqueueResponseAsync(response, cancellation) return result; } - public async ValueTask EnqueueMessageUntypedAsync([NotNull] object message, Type? declaredType = null, CancellationToken cancellation = default) + public async ValueTask EnqueueMessageUntypedAsync([NotNull] object message, Type? declaredType = null, CancellationToken cancellationToken = default) { if (declaredType?.IsInstanceOfType(message) == false) { @@ -125,7 +120,7 @@ public async ValueTask EnqueueMessageUntypedAsync([NotNull] object message if (declaredType != null && typeof(ExternalResponse).IsAssignableFrom(declaredType)) { // EnqueueResponseAsync handles signaling - await this.EnqueueResponseAsync((ExternalResponse)message, cancellation) + await this.EnqueueResponseAsync((ExternalResponse)message, cancellationToken) .ConfigureAwait(false); return true; @@ -133,13 +128,13 @@ await this.EnqueueResponseAsync((ExternalResponse)message, cancellation) else if (declaredType == null && message is ExternalResponse response) { // EnqueueResponseAsync handles signaling - await this.EnqueueResponseAsync(response, cancellation) + await this.EnqueueResponseAsync(response, cancellationToken) .ConfigureAwait(false); return true; } - bool result = await this._stepRunner.EnqueueMessageUntypedAsync(message, declaredType ?? message.GetType(), cancellation) + bool result = await this._stepRunner.EnqueueMessageUntypedAsync(message, declaredType ?? message.GetType(), cancellationToken) .ConfigureAwait(false); // Signal the run loop that new input is available @@ -148,9 +143,9 @@ await this.EnqueueResponseAsync(response, cancellation) return result; } - public async ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation = default) + public async ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default) { - await this._stepRunner.EnqueueResponseAsync(response, cancellation).ConfigureAwait(false); + await this._stepRunner.EnqueueResponseAsync(response, cancellationToken).ConfigureAwait(false); // Signal the run loop that new input is available this.SignalInputToRunLoop(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs index 65b60cd6a8d..c7ac339a0c3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/AsyncRunHandleExtensions.cs @@ -14,33 +14,33 @@ public async static ValueTask> WithCheckpointingAsync(run, runHandle); } - public static async ValueTask EnqueueAndStreamAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellation = default) + public static async ValueTask EnqueueAndStreamAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellationToken = default) { - await runHandle.EnqueueMessageAsync(input, cancellation).ConfigureAwait(false); + await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false); return new(runHandle); } - public static async ValueTask EnqueueUntypedAndStreamAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellation = default) + public static async ValueTask EnqueueUntypedAndStreamAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellationToken = default) { - await runHandle.EnqueueMessageUntypedAsync(input, cancellation: cancellation).ConfigureAwait(false); + await runHandle.EnqueueMessageUntypedAsync(input, cancellationToken: cancellationToken).ConfigureAwait(false); return new(runHandle); } - public static async ValueTask EnqueueAndRunAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellation = default) + public static async ValueTask EnqueueAndRunAsync(this AsyncRunHandle runHandle, TInput input, CancellationToken cancellationToken = default) { - await runHandle.EnqueueMessageAsync(input, cancellation).ConfigureAwait(false); + await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false); Run run = new(runHandle); - await run.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); return run; } - public static async ValueTask EnqueueUntypedAndRunAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellation = default) + public static async ValueTask EnqueueUntypedAndRunAsync(this AsyncRunHandle runHandle, object input, CancellationToken cancellationToken = default) { - await runHandle.EnqueueMessageUntypedAsync(input, cancellation: cancellation).ConfigureAwait(false); + await runHandle.EnqueueMessageUntypedAsync(input, cancellationToken: cancellationToken).ConfigureAwait(false); Run run = new(runHandle); - await run.RunToNextHaltAsync(cancellation).ConfigureAwait(false); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); return run; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/CallResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/CallResult.cs index 655aa1597ab..952b48cd6ce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/CallResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/CallResult.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Execution; @@ -26,16 +27,22 @@ internal sealed class CallResult /// public Exception? Exception { get; init; } + /// + /// Indicated whether the call was cancelled (e.g., via a ). + /// + public bool IsCancelled { get; init; } + /// /// Indicates whether the call was successful. A call is considered successful if it returned /// without throwing an exception. /// - public bool IsSuccess => this.Exception is null; + public bool IsSuccess => this.Exception is null && !this.IsCancelled; - private CallResult(bool isVoid = false) + private CallResult(bool isVoid = false, bool isCancelled = false) { // Private constructor to enforce use of static methods. this.IsVoid = isVoid; + this.IsCancelled = isCancelled; } /// @@ -51,6 +58,14 @@ private CallResult(bool isVoid = false) /// A indicating the result of the call. public static CallResult ReturnVoid() => new(isVoid: true); + /// + /// Create a indicating that the call was cancelled. + /// + /// A boolean specifying whether the call was void (was not expected to return + /// a value). + /// A indicating the result of the call. + public static CallResult Cancelled(bool wasVoid) => new(wasVoid, isCancelled: true); + /// /// Create a indicating that an exception was raised during the call. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ExecutionMode.cs similarity index 100% rename from dotnet/src/Microsoft.Agents.AI.Workflows/ExecutionMode.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ExecutionMode.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs deleted file mode 100644 index ed9f4bf22df..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IInputCoordinator.cs +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Workflows.Execution; - -internal interface IInputCoordinator -{ - ValueTask WaitForNextInputAsync(CancellationToken cancellation = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs index 5ffce6ce366..dfc35c75667 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunEventStream.cs @@ -15,7 +15,7 @@ internal interface IRunEventStream : IAsyncDisposable // this cannot be cancelled ValueTask StopAsync(); - ValueTask GetStatusAsync(CancellationToken cancellation = default); + ValueTask GetStatusAsync(CancellationToken cancellationToken = default); - IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, CancellationToken cancellation = default); + IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs index b3a988306c4..f3fc762336f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/IRunnerContext.cs @@ -1,16 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Workflows.Execution; internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext { - ValueTask AddEventAsync(WorkflowEvent workflowEvent); - ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null); + ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default); + ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default); - ValueTask AdvanceAsync(); + ValueTask AdvanceAsync(CancellationToken cancellationToken = default); IWorkflowContext Bind(string executorId, Dictionary? traceContext = null); - ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer); + ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs index 1e27fe6b4df..f4af19bcfdc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepJoinContext.cs @@ -9,9 +9,11 @@ namespace Microsoft.Agents.AI.Workflows.Execution; internal interface ISuperStepJoinContext { bool WithCheckpointing { get; } + bool ConcurrentRunsEnabled { get; } - ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation = default); - ValueTask SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation = default); + ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default); + ValueTask SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken = default); - ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation = default); + ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default); + ValueTask DetachSuperstepAsync(string id); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs index 7384dfc4d0a..a7923a7d9b1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/ISuperStepRunner.cs @@ -15,11 +15,11 @@ internal interface ISuperStepRunner bool HasUnservicedRequests { get; } bool HasUnprocessedMessages { get; } - ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation = default); + ValueTask EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default); - ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default); - ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default); - ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default); + ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default); + ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default); + ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellationToken = default); ConcurrentEventSink OutgoingEvents { get; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs deleted file mode 100644 index d2fe3268d21..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InitLocked.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; - -namespace Microsoft.Agents.AI.Workflows.Execution; - -internal class InitLocked() where T : class -{ - private int _writers; - private T? _value; - - public T? Get() - { - return this._value; - } - - public bool Init(Func initializer) - { - if (Interlocked.Exchange(ref this._writers, 1) == 0) - { - try - { - if (this._value == null) - { - this._value = initializer(); - return true; - } - - return false; - } - finally - { - this._writers = 0; - } - } - - return false; - } - - public void Clear() - { - this._value = null; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs index f86f3721bb9..d50f284f480 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/InputWaiter.cs @@ -33,10 +33,10 @@ public void SignalInput() } } - public Task WaitForInputAsync(CancellationToken cancellation = default) => this.WaitForInputAsync(null, cancellation); + public Task WaitForInputAsync(CancellationToken cancellationToken = default) => this.WaitForInputAsync(null, cancellationToken); - public async Task WaitForInputAsync(TimeSpan? timeout = null, CancellationToken cancellation = default) + public async Task WaitForInputAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default) { - await this._inputSignal.WaitAsync(timeout ?? TimeSpan.FromMilliseconds(-1), cancellation).ConfigureAwait(false); + await this._inputSignal.WaitAsync(timeout ?? TimeSpan.FromMilliseconds(-1), cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs index a66f0af9781..b47a692113e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/LockstepRunEventStream.cs @@ -22,7 +22,7 @@ internal sealed class LockstepRunEventStream : IRunEventStream private readonly ISuperStepRunner _stepRunner; - public ValueTask GetStatusAsync(CancellationToken cancellation = default) => new(this.RunStatus); + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) => new(this.RunStatus); public LockstepRunEventStream(ISuperStepRunner stepRunner) { @@ -36,7 +36,7 @@ public void Start() // No-op for lockstep execution } - public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellation = default) + public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPendingRequest, [EnumeratorCancellation] CancellationToken cancellationToken = default) { #if NET ObjectDisposedException.ThrowIf(Volatile.Read(ref this._isDisposed) == 1, this); @@ -47,7 +47,7 @@ public async IAsyncEnumerable TakeEventStreamAsync(bool blockOnPe } #endif - CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellation); + CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(this._stopCancellation.Token, cancellationToken); ConcurrentQueue eventSink = []; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/MessageRouter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/MessageRouter.cs index 0a23105c74c..10ce345ad8f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/MessageRouter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/MessageRouter.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Shared.Diagnostics; @@ -11,12 +12,14 @@ System.Func< Microsoft.Agents.AI.Workflows.PortableValue, // message Microsoft.Agents.AI.Workflows.IWorkflowContext, // context + System.Threading.CancellationToken, // cancellation System.Threading.Tasks.ValueTask >; using MessageHandlerF = System.Func< object, // message Microsoft.Agents.AI.Workflows.IWorkflowContext, // context + System.Threading.CancellationToken, // cancellation System.Threading.Tasks.ValueTask >; @@ -56,7 +59,7 @@ public bool CanHandle(TypeId candidateType) public HashSet DefaultOutputTypes { get; } - public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false) + public async ValueTask RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false, CancellationToken cancellationToken = default) { Throw.IfNull(message); @@ -74,13 +77,13 @@ public bool CanHandle(TypeId candidateType) { if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler)) { - result = await handler(message, context).ConfigureAwait(false); + result = await handler(message, context, cancellationToken).ConfigureAwait(false); } else if (this.HasCatchAll) { portableValue ??= new PortableValue(message); - result = await this._catchAllFunc(portableValue, context).ConfigureAwait(false); + result = await this._catchAllFunc(portableValue, context, cancellationToken).ConfigureAwait(false); } } catch (Exception e) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs index 3e7ef91e0c8..81ffedc6aff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Checkpointing; @@ -97,31 +98,81 @@ public async ValueTask> ReadKeysAsync(ScopeId scopeId) public ValueTask ReadStateAsync(string executorId, string? scopeName, string key) => this.ReadStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key); - public ValueTask ReadStateAsync(ScopeId scopeId, string key) + public ValueTask ReadOrInitStateAsync(string executorId, string? scopeName, string key, Func initialStateFactory) + => this.ReadOrInitStateAsync(new ScopeId(Throw.IfNullOrEmpty(executorId), scopeName), key, initialStateFactory); + + private async ValueTask ReadValueOrDefaultAsync(ScopeId scopeId, string key, Func? defaultValueFactory = default, bool initOnDefault = false) { + if (typeof(T) == typeof(object)) + { + // Reading as object will break across serialize/deserialize boundaries, e.g. checkpointing, distributed runtime, etc. + // Disabled pending upstream updates for this change; see https://github.com/microsoft/agent-framework/issues/1369 + //throw new NotSupportedException("Reading state as 'object' is not supported. Use 'PortableValue' instead for variants."); + } + Throw.IfNullOrEmpty(key); UpdateKey stateKey = new(scopeId, key); + T? result = defaultValueFactory != null ? defaultValueFactory() : default; + bool needsInit = false; + // If there is executor-local state (from a queued update), read it first - if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? result)) + if (this._queuedUpdates.TryGetValue(stateKey, out StateUpdate? update)) { // What's the right thing to do when we have a state object, but it is the wrong type? - if (result.IsDelete) + if (update.IsDelete || update.Value is null) + { + needsInit = initOnDefault; + } + else if (update.Value is T typed) + { + result = typed; + } + else if (typeof(T) == typeof(PortableValue) && update.Value != null) + { + result = (T)(object)new PortableValue(update.Value); + } + else + { + throw new InvalidOperationException($"State for key '{key}' in scope '{scopeId}' is not of type '{typeof(T).Name}'."); + } + } + else + { + StateScope scope = this.GetOrCreateScope(scopeId); + if (scope.ContainsKey(key)) { - return new((T?)default); + result = await scope.ReadStateAsync(key).ConfigureAwait(false); } + else if (initOnDefault) + { + needsInit = true; + } + } - if (result.Value is T) + if (needsInit) + { + if (defaultValueFactory is null) { - return new((T?)result.Value); + throw new ArgumentNullException(nameof(defaultValueFactory), "Default value must be provided when initializing state."); } - throw new InvalidOperationException($"State for key '{key}' in scope '{scopeId}' is not of type '{typeof(T).Name}'."); + Debug.Assert(initOnDefault); + + await this.WriteStateAsync(scopeId, key, defaultValueFactory()).ConfigureAwait(false); } - StateScope scope = this.GetOrCreateScope(scopeId); - return scope.ReadStateAsync(key); + return result; + } + + public ValueTask ReadStateAsync(ScopeId scopeId, string key) + => this.ReadValueOrDefaultAsync(scopeId, key); + + public async ValueTask ReadOrInitStateAsync(ScopeId scopeId, string key, Func initialStateFactory) + { + return (await this.ReadValueOrDefaultAsync(scopeId, key, initialStateFactory, initOnDefault: true) + .ConfigureAwait(false))!; } public ValueTask WriteStateAsync(string executorId, string? scopeName, string key, T value) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs index 607f97c3513..e1c50ab1a3f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StateScope.cs @@ -51,6 +51,13 @@ public bool ContainsKey(string key) Throw.IfNullOrEmpty(key); if (this._stateData.TryGetValue(key, out PortableValue? value)) { + if (typeof(T) == typeof(PortableValue) && !value.TypeId.IsMatch(typeof(PortableValue))) + { + // value is PortableValue, and we do not need to unwrap a PortableValue instance inside of it + // Unfortunately we need to cast through object here. + return new((T)(object)value); + } + return new(value.As()); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index 0f941ea7336..718ebcd11c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -50,10 +50,10 @@ public void Start() } } - private async Task RunLoopAsync(CancellationToken cancellation) + private async Task RunLoopAsync(CancellationToken cancellationToken) { using CancellationTokenSource errorSource = new(); - CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellation); + CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource(errorSource.Token, cancellationToken); // Subscribe to events - they will flow directly to the channel as they're raised this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync; @@ -62,7 +62,7 @@ private async Task RunLoopAsync(CancellationToken cancellation) { // Wait for the first input before starting // The consumer will call EnqueueMessageAsync which signals the run loop - await this._inputWaiter.WaitForInputAsync(cancellation: linkedSource.Token).ConfigureAwait(false); + await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false); this._runStatus = RunStatus.Running; @@ -134,7 +134,7 @@ async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e) public async IAsyncEnumerable TakeEventStreamAsync( bool blockOnPendingRequest, - [EnumeratorCancellation] CancellationToken cancellation = default) + [EnumeratorCancellation] CancellationToken cancellationToken = default) { // Get the current epoch - we'll only respond to completion signals from this epoch or later int myEpoch = Volatile.Read(ref this._completionEpoch) + 1; @@ -143,7 +143,7 @@ public async IAsyncEnumerable TakeEventStreamAsync( // Note: When cancellation is requested, ReadAllAsync may throw OperationCanceledException // or may complete the enumeration. We check IsCancellationRequested explicitly at superstep // boundaries to ensure clean cancellation. - await foreach (WorkflowEvent evt in this._eventChannel.Reader.ReadAllAsync(cancellation).ConfigureAwait(false)) + await foreach (WorkflowEvent evt in this._eventChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { // Filter out internal signals used for run loop coordination if (evt is InternalHaltSignal completionSignal) @@ -156,7 +156,7 @@ public async IAsyncEnumerable TakeEventStreamAsync( // Check for cancellation at superstep boundaries (before processing completion signal) // This allows consumers to stop reading events cleanly between supersteps - if (cancellation.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) { yield break; } @@ -186,7 +186,7 @@ public async IAsyncEnumerable TakeEventStreamAsync( yield break; } - if (cancellation.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) { yield break; } @@ -195,7 +195,7 @@ public async IAsyncEnumerable TakeEventStreamAsync( } } - public ValueTask GetStatusAsync(CancellationToken cancellation = default) + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) { // Thread-safe read of status (enum is read atomically on most platforms) return new ValueTask(this._runStatus); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index e1df4fb37ec..97c7932cf0a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -32,12 +32,25 @@ public abstract class Executor : IIdentified /// /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. - protected Executor(string id, ExecutorOptions? options = null) + /// Declare that this executor may be used simultaneously by multiple runs safely. + protected Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) { this.Id = id; this.Options = options ?? ExecutorOptions.Default; + + //if (declareCrossRunShareable && this is IResettableExecutor) + //{ + // // We need a way to be able to let the user override this at the workflow level too, because knowing the fine + // // details of when to use which of these paths seems like it could be tricky, and we should not force users + // // to do this; instead container agents should set this when they intiate the run (via WorkflowHostAgent). + // throw new ArgumentException("An executor that is declared as cross-run shareable cannot also be resettable."); + //} + + this.IsCrossRunShareable = declareCrossRunShareable; } + internal bool IsCrossRunShareable { get; } + /// /// Gets the configuration options for the executor. /// @@ -48,6 +61,16 @@ protected Executor(string id, ExecutorOptions? options = null) /// protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder); + /// + /// Perform any asynchronous initialization required by the executor. This method is called once per executor instance, + /// + /// The workflow context in which the executor executes. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + => default; + /// /// Override this method to declare the types of messages this executor can send. /// @@ -90,10 +113,12 @@ internal MessageRouter Router /// The "declared" type of the message (captured when it was being sent). This is /// used to enable routing messages as their base types, in absence of true polymorphic type routing. /// The workflow context in which the executor executes. + /// The to monitor for cancellation requests. + /// The default is . /// A ValueTask representing the asynchronous operation, wrapping the output from the executor. /// No handler found for the message type. /// An exception is generated while handling the message. - public async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context) + public async ValueTask ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default) { using var activity = s_activitySource.StartActivity(ActivityNames.ExecutorProcess, ActivityKind.Internal); activity?.SetTag(Tags.ExecutorId, this.Id) @@ -101,9 +126,9 @@ internal MessageRouter Router .SetTag(Tags.MessageType, messageType.TypeName) .CreateSourceLinks(context.TraceContext); - await context.AddEventAsync(new ExecutorInvokedEvent(this.Id, message)).ConfigureAwait(false); + await context.AddEventAsync(new ExecutorInvokedEvent(this.Id, message), cancellationToken).ConfigureAwait(false); - CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true) + CallResult? result = await this.Router.RouteMessageAsync(message, context, requireRoute: true, cancellationToken) .ConfigureAwait(false); ExecutorEvent executionResult; @@ -116,7 +141,7 @@ internal MessageRouter Router executionResult = new ExecutorFailedEvent(this.Id, result.Exception); } - await context.AddEventAsync(executionResult).ConfigureAwait(false); + await context.AddEventAsync(executionResult, cancellationToken).ConfigureAwait(false); if (result is null) { @@ -137,11 +162,11 @@ internal MessageRouter Router // If we had a real return type, raise it as a SendMessage; TODO: Should we have a way to disable this behaviour? if (result.Result is not null && this.Options.AutoSendMessageHandlerResultObject) { - await context.SendMessageAsync(result.Result).ConfigureAwait(false); + await context.SendMessageAsync(result.Result, cancellationToken: cancellationToken).ConfigureAwait(false); } if (result.Result is not null && this.Options.AutoYieldOutputHandlerResultObject) { - await context.YieldOutputAsync(result.Result).ConfigureAwait(false); + await context.YieldOutputAsync(result.Result, cancellationToken).ConfigureAwait(false); } return result.Result; @@ -152,7 +177,8 @@ internal MessageRouter Router /// /// The workflow context. /// A ValueTask representing the asynchronous operation. - /// The to monitor for cancellation requests. The default is . + /// The to monitor for cancellation requests. + /// The default is . protected internal virtual ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; /// @@ -160,7 +186,8 @@ internal MessageRouter Router /// /// The workflow context. /// A ValueTask representing the asynchronous operation. - /// The to monitor for cancellation requests. The default is . + /// The to monitor for cancellation requests. + /// The default is . protected internal virtual ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => default; /// @@ -202,15 +229,16 @@ internal bool CanOutput(Type messageType) /// The type of input message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string id, ExecutorOptions? options = null) - : Executor(id, options), IMessageHandler +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : Executor(id, options, declareCrossRunShareable), IMessageHandler { /// protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler(this.HandleAsync); /// - public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context); + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); } /// @@ -220,8 +248,9 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => /// The type of output message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. -public abstract class Executor(string id, ExecutorOptions? options = null) - : Executor(id, options ?? ExecutorOptions.Default), +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class Executor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : Executor(id, options ?? ExecutorOptions.Default, declareCrossRunShareable), IMessageHandler { /// @@ -229,5 +258,5 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder.AddHandler(this.HandleAsync); /// - public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context); + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs index 59c26284d83..2151dadce47 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorIsh.cs @@ -13,6 +13,43 @@ namespace Microsoft.Agents.AI.Workflows; /// public static class ExecutorIshConfigurationExtensions { + /// + /// Configures a factory method for creating an of type , using the + /// type name as the id. + /// + /// + /// Note that Executor Ids must be unique within a workflow. + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, if this is used as a start node of a typed via , + /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the + /// demanded TInput. + /// + /// The type of the resulting executor + /// The factory method. + /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorIsh ConfigureFactory(this Func> factoryAsync) + where TExecutor : Executor + => ConfigureFactory((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null); + + /// + /// Configures a factory method for creating an of type , with + /// the specified id. + /// + /// + /// Although this will generally result in a delay-instantiated once messages are available + /// for it, if this is used as a start node of a typed via , + /// it will be instantiated as part of the workflow's construction, to validate that its input type matches the + /// demanded TInput. + /// + /// The type of the resulting executor + /// The factory method. + /// An id for the executor to be instantiated. + /// An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it. + public static ExecutorIsh ConfigureFactory(this Func> factoryAsync, string id) + where TExecutor : Executor + => ConfigureFactory((_, runId) => factoryAsync(id, runId), id, options: null); + /// /// Configures a factory method for creating an of type , with /// the specified id and options. @@ -77,9 +114,10 @@ ValueTask InitHostExecutorAsync(Config co /// A delegate that defines the asynchronous function to execute for each input message. /// A optional unique identifier for the executor. If null, will use the function argument as an id. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(id, messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); + public static ExecutorIsh AsExecutor(this Func messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); /// /// Configures a function-based asynchronous message handler as an executor with the specified identifier and @@ -90,9 +128,10 @@ public static ExecutorIsh AsExecutor(this FuncA delegate that defines the asynchronous function to execute for each input message. /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null) - => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options).ToExecutorIsh(messageHandlerAsync); + public static ExecutorIsh AsExecutor(this Func> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new FunctionExecutor(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync); /// /// Configures a function-based aggregating executor with the specified identifier and options. @@ -102,9 +141,10 @@ public static ExecutorIsh AsExecutor(this FuncA delegate the defines the aggregation procedure /// A unique identifier for the executor. /// Configuration options for the executor. If null, default options will be used. + /// Declare that the message handler may be used simultaneously by multiple runs concurrently. /// An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration. - public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null) - => new AggregatingExecutor(id, aggregatorFunc, options); + public static ExecutorIsh AsExecutor(this Func aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false) + => new AggregatingExecutor(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs index 80f57680e6d..661cde559f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ExecutorRegistration.cs @@ -14,7 +14,13 @@ internal sealed class ExecutorRegistration(string id, Type executorType, Executo public Type ExecutorType { get; } = Throw.IfNull(executorType); private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider); public bool IsNotExecutorInstance { get; } = rawData is not Executor; - public bool IsUnresettableSharedInstance { get; } = rawData is Executor && rawData is not IResettableExecutor; + public bool IsUnresettableSharedInstance { get; } = rawData is Executor executor && + // Cross-Run Shareable executors are "trivially" resettable, since they + // have no on-object state. + !executor.IsCrossRunShareable && + rawData is not IResettableExecutor; + public bool SupportsConcurrent { get; } = (rawData is not Executor executor || executor.IsCrossRunShareable) && + (rawData is not Workflow workflow || workflow.AllowConcurrent); internal async ValueTask TryResetAsync() { @@ -23,9 +29,8 @@ internal async ValueTask TryResetAsync() return false; } - // If this is not an executor instance, this is a factory, and the expectation is that the factory will - // create separate instances of executors. - if (this.IsNotExecutorInstance) + // If the executor supports concurrent use, then resetting is a no-op. + if (this.SupportsConcurrent) { return true; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs index 0db15f42bfd..63db9456b4c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs @@ -13,9 +13,11 @@ namespace Microsoft.Agents.AI.Workflows; /// A unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. /// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. public class FunctionExecutor(string id, Func handlerAsync, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { internal static Func WrapAction(Action handlerSync) { @@ -29,7 +31,7 @@ ValueTask RunActionAsync(TInput input, IWorkflowContext workflowContext, Cancell } /// - public override ValueTask HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default); + public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken); /// /// Creates a new instance of the class. @@ -49,9 +51,11 @@ public FunctionExecutor(string id, ActionA unique identifier for the executor. /// A delegate that defines the asynchronous function to execute for each input message. /// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. public class FunctionExecutor(string id, Func> handlerAsync, - ExecutorOptions? options = null) : Executor(id, options) + ExecutorOptions? options = null, + bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { internal static Func> WrapFunc(Func handlerSync) { @@ -65,7 +69,7 @@ ValueTask RunFuncAsync(TInput input, IWorkflowContext workflowContext, } /// - public override ValueTask HandleAsync(TInput message, IWorkflowContext context) => handlerAsync(message, context, default); + public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken); /// /// Creates a new instance of the class. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs new file mode 100644 index 00000000000..9d3d55b33fd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatManager.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// A manager that manages the flow of a group chat. +/// +public abstract class GroupChatManager +{ + private int _maximumIterationCount = 40; + + /// + /// Initializes a new instance of the class. + /// + protected GroupChatManager() { } + + /// + /// Gets the number of iterations in the group chat so far. + /// + public int IterationCount { get; internal set; } + + /// + /// Gets or sets the maximum number of iterations allowed. + /// + /// + /// Each iteration involves a single interaction with a participating agent. + /// The default is 40. + /// + public int MaximumIterationCount + { + get => this._maximumIterationCount; + set => this._maximumIterationCount = Throw.IfLessThan(value, 1); + } + + /// + /// Selects the next agent to participate in the group chat based on the provided chat history and team. + /// + /// The chat history to consider. + /// The to monitor for cancellation requests. + /// The default is . + /// The next to speak. This agent must be part of the chat. + protected internal abstract ValueTask SelectNextAgentAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default); + + /// + /// Filters the chat history before it's passed to the next agent. + /// + /// The chat history to filter. + /// The to monitor for cancellation requests. + /// The default is . + /// The filtered chat history. + protected internal virtual ValueTask> UpdateHistoryAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(history); + + /// + /// Determines whether the group chat should be terminated based on the provided chat history and iteration count. + /// + /// The chat history to consider. + /// The to monitor for cancellation requests. + /// The default is . + /// A indicating whether the chat should be terminated. + protected internal virtual ValueTask ShouldTerminateAsync( + IReadOnlyList history, + CancellationToken cancellationToken = default) => + new(this.MaximumIterationCount is int max && this.IterationCount >= max); + + /// + /// Resets the state of the manager for a new group chat session. + /// + protected internal virtual void Reset() + { + this.IterationCount = 0; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs new file mode 100644 index 00000000000..92b73083a96 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/GroupChatWorkflowBuilder.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides a builder for specifying group chat relationships between agents and building the resulting workflow. +/// +public sealed class GroupChatWorkflowBuilder +{ + private readonly Func, GroupChatManager> _managerFactory; + private readonly HashSet _participants = new(AIAgentIDEqualityComparer.Instance); + + internal GroupChatWorkflowBuilder(Func, GroupChatManager> managerFactory) => + this._managerFactory = managerFactory; + + /// + /// Adds the specified as participants to the group chat workflow. + /// + /// The agents to add as participants. + /// This instance of the . + public GroupChatWorkflowBuilder AddParticipants(params IEnumerable agents) + { + Throw.IfNull(agents); + + foreach (var agent in agents) + { + if (agent is null) + { + Throw.ArgumentNullException(nameof(agents), "One or more target agents are null."); + } + + this._participants.Add(agent); + } + + return this; + } + + /// + /// Builds a composed of agents that operate via group chat, with the next + /// agent to process messages selected by the group chat manager. + /// + /// The workflow built based on the group chat in the builder. + public Workflow Build() + { + AIAgent[] agents = this._participants.ToArray(); + Dictionary agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true)); + + Func> groupChatHostFactory = + (string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory)); + + ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost)); + WorkflowBuilder builder = new(host); + + foreach (var participant in agentMap.Values) + { + builder + .AddEdge(host, participant) + .AddEdge(participant, host); + } + + return builder.WithOutputFrom(host).Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs new file mode 100644 index 00000000000..4362f0834fc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow. +/// +public sealed class HandoffsWorkflowBuilder +{ + internal const string FunctionPrefix = "handoff_to_"; + private readonly AIAgent _initialAgent; + private readonly Dictionary> _targets = []; + private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance); + + /// + /// Initializes a new instance of the class with no handoff relationships. + /// + /// The first agent to be invoked (prior to any handoff). + internal HandoffsWorkflowBuilder(AIAgent initialAgent) + { + this._initialAgent = initialAgent; + this._allAgents.Add(initialAgent); + } + + /// + /// Gets or sets additional instructions to provide to an agent that has handoffs about how and when to perform them. + /// + /// + /// By default, simple instructions are included. This may be set to to avoid including + /// any additional instructions, or may be customized to provide more specific guidance. + /// + public string? HandoffInstructions { get; set; } = + $""" + You are one agent in a multi-agent system. You can hand off the conversation to another agent if appropriate. Handoffs are achieved + by calling a handoff function, named in the form `{FunctionPrefix}`; the description of the function provides details on the + target agent of that handoff. Handoffs between agents are handled seamlessly in the background; never mention or narrate these handoffs + in your conversation with the user. + """; + + /// + /// Adds handoff relationships from a source agent to one or more target agents. + /// + /// The source agent. + /// The target agents to add as handoff targets for the source agent. + /// The updated instance. + /// The handoff reason for each target in is derived from that agent's description or name. + public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to) + { + Throw.IfNull(from); + Throw.IfNull(to); + + foreach (var target in to) + { + if (target is null) + { + Throw.ArgumentNullException(nameof(to), "One or more target agents are null."); + } + + this.WithHandoff(from, target); + } + + return this; + } + + /// + /// Adds handoff relationships from one or more sources agent to a target agent. + /// + /// The source agents. + /// The target agent to add as a handoff target for each source agent. + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// + /// The updated instance. + public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null) + { + Throw.IfNull(from); + Throw.IfNull(to); + + foreach (var source in from) + { + if (source is null) + { + Throw.ArgumentNullException(nameof(from), "One or more source agents are null."); + } + + this.WithHandoff(source, to, handoffReason); + } + + return this; + } + + /// + /// Adds a handoff relationship from a source agent to a target agent with a custom handoff reason. + /// + /// The source agent. + /// The target agent. + /// + /// The reason the should hand off to the . + /// If , the reason is derived from 's description or name. + /// + /// The updated instance. + public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null) + { + Throw.IfNull(from); + Throw.IfNull(to); + + this._allAgents.Add(from); + this._allAgents.Add(to); + + if (!this._targets.TryGetValue(from, out var handoffs)) + { + this._targets[from] = handoffs = []; + } + + if (string.IsNullOrWhiteSpace(handoffReason)) + { + handoffReason = to.Description ?? to.Name ?? (to as ChatClientAgent)?.Instructions; + if (string.IsNullOrWhiteSpace(handoffReason)) + { + Throw.ArgumentException( + nameof(to), + $"The provided target agent '{to.DisplayName}' has no description, name, or instructions, and no handoff description has been provided. " + + "At least one of these is required to register a handoff so that the appropriate target agent can be chosen."); + } + } + + if (!handoffs.Add(new(to, handoffReason))) + { + Throw.InvalidOperationException($"A handoff from agent '{from.DisplayName}' to agent '{to.DisplayName}' has already been registered."); + } + + return this; + } + + /// + /// Builds a composed of agents that operate via handoffs, with the next + /// agent to process messages selected by the current agent. + /// + /// The workflow built based on the handoffs in the builder. + public Workflow Build() + { + HandoffsStartExecutor start = new(); + HandoffsEndExecutor end = new(); + WorkflowBuilder builder = new(start); + + // Create an AgentExecutor for each again. + Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, this.HandoffInstructions)); + + // Connect the start executor to the initial agent. + builder.AddEdge(start, executors[this._initialAgent.Id]); + + // Initialize each executor with its handoff targets to the other executors. + foreach (var agent in this._allAgents) + { + executors[agent.Id].Initialize(builder, end, executors, + this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); + } + + // Build the workflow. + return builder.WithOutputFrom(end).Build(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs index eed3b15d722..57dcdc2b641 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/IWorkflowContext.cs @@ -1,10 +1,66 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Workflows; +/// +/// Provides extension methods for working with instances. +/// +public static class WorkflowContextExtensions +{ + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// The type of the state object to read, update, and persist. + /// The workflow context used to access and update state. + /// A delegate that receives the current state, workflow context, and cancellation token, and returns the updated + /// state asynchronously. + /// The key identifying the state to read and update. Cannot be null or empty. + /// An optional scope name that further qualifies the state key. If null, the default scope is used. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. + public static async ValueTask InvokeWithStateAsync(this IWorkflowContext context, + Func> invocation, + string key, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + TState? state = await context.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false); + state = await invocation(state, context, cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(key, state, scopeName, cancellationToken).ConfigureAwait(false); + } + + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// The type of the state object to read, update, and persist. + /// The workflow context used to access and update state. + /// A delegate that receives the current state, workflow context, and cancellation token, and returns the updated + /// state asynchronously. + /// The key identifying the state to read and update. Cannot be null or empty. + /// A factory to initialize state to if it is not set at the provided key. + /// An optional scope name that further qualifies the state key. If null, the default scope is used. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A ValueTask that represents the asynchronous operation. + public static async ValueTask InvokeWithStateAsync(this IWorkflowContext context, + Func> invocation, + string key, + Func initialStateFactory, + string? scopeName = null, + CancellationToken cancellationToken = default) + { + TState? state = await context.ReadOrInitStateAsync(key, initialStateFactory, scopeName, cancellationToken).ConfigureAwait(false); + state = await invocation(state, context, cancellationToken).ConfigureAwait(false); + await context.QueueStateUpdateAsync(key, state ?? initialStateFactory(), scopeName, cancellationToken).ConfigureAwait(false); + } +} + /// /// Provides services for an during the execution of a workflow. /// @@ -15,8 +71,10 @@ public interface IWorkflowContext /// end of the current SuperStep. /// /// The event to be raised. + /// The to monitor for cancellation requests. + /// The default is . /// A representing the asynchronous operation. - ValueTask AddEventAsync(WorkflowEvent workflowEvent); + ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default); /// /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. @@ -25,8 +83,22 @@ public interface IWorkflowContext /// An optional identifier of the target executor. If null, the message is sent to all connected /// executors. If the target executor is not connected from this executor via an edge, it will still not receive the /// message. + /// The to monitor for cancellation requests. + /// The default is . /// A representing the asynchronous operation. - ValueTask SendMessageAsync(object message, string? targetId = null); + ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default); + +#if NET // What's the right way to do this so we do not make life a misery for netstandard2.0 targets? + // What's the value if they have to still write `cancellationToken: cancellationToken` to skip the targetId parameter? + // TODO: Remove this? (Maybe not: NET will eventually be the only target framework, right?) + /// + /// Queues a message to be sent to connected executors. The message will be sent during the next SuperStep. + /// + /// The message to be sent. + /// The to monitor for cancellation requests. + /// A representing the asynchronous operation. + ValueTask SendMessageAsync(object message, CancellationToken cancellationToken) => this.SendMessageAsync(message, null, cancellationToken); +#endif /// /// Adds an output value to the workflow's output queue. These outputs will be bubbled out of the workflow using the @@ -37,8 +109,10 @@ public interface IWorkflowContext /// types of registered message handlers are considered output types, unless otherwise specified using . /// /// The output value to be returned. + /// The to monitor for cancellation requests. + /// The default is . /// A representing the asynchronous operation. - ValueTask YieldOutputAsync(object output); + ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default); /// /// Adds a request to "halt" workflow execution at the end of the current SuperStep. @@ -54,15 +128,63 @@ public interface IWorkflowContext /// The key of the state value. /// An optional name that specifies the scope to read.If null, the default scope is /// used. + /// The to monitor for cancellation requests. + /// The default is . /// A representing the asynchronous operation. - ValueTask ReadStateAsync(string key, string? scopeName = null); + ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default); + + /// + /// Reads or initialized a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. + /// + /// + /// When initializing the state, the state will be queued as an update. If multiple initializations are done in the same + /// SuperStep from different executors, an error will be generated at the end of the SuperStep. + /// + /// The type of the state value. + /// The key of the state value. + /// A factory to initialize the state if the key has no value associated with it. + /// An optional name that specifies the scope to read. If null, the default scope is + /// used. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default); + +#if NET // See above for musings about this construction + /// + /// Reads a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. + /// + /// The type of the state value. + /// The key of the state value. + /// The to monitor for cancellation requests. + /// A representing the asynchronous operation. + ValueTask ReadStateAsync(string key, CancellationToken cancellationToken) + => this.ReadStateAsync(key, null, cancellationToken); + + /// + /// Reads a state value from the workflow's state store. If no scope is provided, the executor's + /// default scope is used. + /// + /// The type of the state value. + /// The key of the state value. + /// A factory to initialize the state if the key has no value associated with it. + /// The to monitor for cancellation requests. + /// The default is . + /// A representing the asynchronous operation. + ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, CancellationToken cancellationToken) + => this.ReadOrInitStateAsync(key, initialStateFactory, null, cancellationToken); +#endif /// /// Asynchronously reads all state keys within the specified scope. /// /// An optional name that specifies the scope to read. If null, the default scope is /// used. - ValueTask> ReadStateKeysAsync(string? scopeName = null); + /// The to monitor for cancellation requests. + /// The default is . + ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default); /// /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. @@ -77,8 +199,27 @@ public interface IWorkflowContext /// implementation. /// An optional name that specifies the scope to update. If null, the default scope is /// used. + /// The to monitor for cancellation requests. + /// The default is . + /// A ValueTask that represents the asynchronous update operation. + ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default); + +#if NET // See above for musings about this construction + /// + /// Asynchronously updates the state of a queue entry identified by the specified key and optional scope. + /// + /// + /// Subsequent reads by this executor will result in the new value of the state. Other executors will only see + /// the new state starting from the next SuperStep. + /// + /// The type of the value to associate with the queue entry. + /// The unique identifier for the queue entry to update. Cannot be null or empty. + /// The value to set for the queue entry. If null, the entry's state may be cleared or reset depending on + /// implementation. + /// The to monitor for cancellation requests. /// A ValueTask that represents the asynchronous update operation. - ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null); + ValueTask QueueStateUpdateAsync(string key, T? value, CancellationToken cancellationToken) => this.QueueStateUpdateAsync(key, value, null, cancellationToken); +#endif /// /// Asynchronously clears all state entries within the specified scope. @@ -90,11 +231,33 @@ public interface IWorkflowContext /// see the cleared state starting from the next SuperStep. /// /// An optional name that specifies the scope to clear. If null, the default scope is used. + /// The to monitor for cancellation requests. + /// The default is . /// A ValueTask that represents the asynchronous clear operation. - ValueTask QueueClearScopeAsync(string? scopeName = null); + ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default); + +#if NET // See above for musings about this construction + /// + /// Asynchronously clears all state entries within the specified scope. + /// + /// This semantically equivalent to retrieving all keys in the scope and deleting them one-by-one. + /// + /// + /// Subsequent reads by this executor will not find any entries in the cleared scope. Other executors will only + /// see the cleared state starting from the next SuperStep. + /// + /// The to monitor for cancellation requests. + /// A ValueTask that represents the asynchronous clear operation. + ValueTask QueueClearScopeAsync(CancellationToken cancellationToken) => this.QueueClearScopeAsync(null, cancellationToken); +#endif /// /// The trace context associated with the current message about to be processed by the executor, if any. /// IReadOnlyDictionary? TraceContext { get; } + + /// + /// Whether the current execution environment support concurrent runs against the same workflow instance. + /// + bool ConcurrentRunsEnabled { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs index 67c35099bcd..219b4642cd0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionEnvironment.cs @@ -15,22 +15,25 @@ namespace Microsoft.Agents.AI.Workflows.InProc; /// public sealed class InProcessExecutionEnvironment : IWorkflowExecutionEnvironment { - private readonly ExecutionMode _executionMode; - internal InProcessExecutionEnvironment(ExecutionMode mode) + internal InProcessExecutionEnvironment(ExecutionMode mode, bool enableConcurrentRuns = false) { - this._executionMode = mode; + this.ExecutionMode = mode; + this.EnableConcurrentRuns = enableConcurrentRuns; } + internal ExecutionMode ExecutionMode { get; } + internal bool EnableConcurrentRuns { get; } + internal ValueTask BeginRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, IEnumerable knownValidInputTypes, CancellationToken cancellationToken) { - InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes); - return runner.BeginStreamAsync(this._executionMode, cancellationToken); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, runId, this.EnableConcurrentRuns, knownValidInputTypes); + return runner.BeginStreamAsync(this.ExecutionMode, cancellationToken); } internal ValueTask ResumeRunAsync(Workflow workflow, ICheckpointManager? checkpointManager, string? runId, CheckpointInfo fromCheckpoint, IEnumerable knownValidInputTypes, CancellationToken cancellationToken) { - InProcessRunner runner = new(workflow, checkpointManager, runId, knownValidInputTypes: knownValidInputTypes); - return runner.ResumeStreamAsync(this._executionMode, fromCheckpoint, cancellationToken); + InProcessRunner runner = InProcessRunner.CreateTopLevelRunner(workflow, checkpointManager, runId, this.EnableConcurrentRuns, knownValidInputTypes); + return runner.ResumeStreamAsync(this.ExecutionMode, fromCheckpoint, cancellationToken); } /// @@ -126,10 +129,11 @@ public async ValueTask RunAsync( string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull { - AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [], cancellationToken) - .ConfigureAwait(false); + var runHandle = await this.GetRunHandleWithTurnTokenAsync(workflow: workflow, input: input, checkpointManager: null, runId: runId, cancellationToken).ConfigureAwait(false); - return await runHandle.EnqueueAndRunAsync(input, cancellationToken).ConfigureAwait(false); + Run run = new(runHandle); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); + return run; } /// @@ -139,10 +143,11 @@ public async ValueTask RunAsync( string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull { - AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager: null, runId: runId, [typeof(TInput)], cancellationToken) - .ConfigureAwait(false); + var runHandle = await this.GetRunHandleWithTurnTokenAsync(workflow: workflow, input: input, checkpointManager: null, runId: runId, cancellationToken).ConfigureAwait(false); - return await runHandle.EnqueueAndRunAsync(input, cancellationToken).ConfigureAwait(false); + Run run = new(runHandle); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); + return run; } /// @@ -153,10 +158,11 @@ public async ValueTask> RunAsync( string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull { - AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager, runId: runId, [], cancellationToken) - .ConfigureAwait(false); + var runHandle = await this.GetRunHandleWithTurnTokenAsync(workflow: workflow, input: input, checkpointManager: checkpointManager, runId: runId, cancellationToken).ConfigureAwait(false); - return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndRunAsync(input, cancellationToken)) + Run run = new(runHandle); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); + return await runHandle.WithCheckpointingAsync(() => new ValueTask(run)) .ConfigureAwait(false); } @@ -168,10 +174,11 @@ public async ValueTask> RunAsync( string? runId = null, CancellationToken cancellationToken = default) where TInput : notnull { - AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager, runId: runId, [typeof(TInput)], cancellationToken) - .ConfigureAwait(false); + var runHandle = await this.GetRunHandleWithTurnTokenAsync(workflow: workflow, input: input, checkpointManager: checkpointManager, runId: runId, cancellationToken).ConfigureAwait(false); - return await runHandle.WithCheckpointingAsync(() => runHandle.EnqueueAndRunAsync(input, cancellationToken)) + Run run = new(runHandle); + await run.RunToNextHaltAsync(cancellationToken).ConfigureAwait(false); + return await runHandle.WithCheckpointingAsync(() => new ValueTask(run)) .ConfigureAwait(false); } @@ -204,4 +211,48 @@ public async ValueTask> ResumeAsync( return await runHandle.WithCheckpointingAsync(() => new(new Run(runHandle))) .ConfigureAwait(false); } + + // Helper to construct a RunHandle with the provided input enqueued. If the starting executor supports it, a TurnToken will be enqueued also. + private async ValueTask GetRunHandleWithTurnTokenAsync( + Workflow workflow, + TInput input, + CheckpointManager? checkpointManager, + string? runId, + CancellationToken cancellationToken) + { + var knownTypes = new List() { typeof(TInput) }; + var needsTurnToken = await StartingExecutorHandlesTurnTokenAsync(workflow).ConfigureAwait(false); + if (needsTurnToken) + { + knownTypes.Add(typeof(TurnToken)); + } + + AsyncRunHandle runHandle = await this.BeginRunAsync(workflow, checkpointManager: checkpointManager, runId: runId, knownTypes, cancellationToken) + .ConfigureAwait(false); + + await runHandle.EnqueueMessageAsync(input, cancellationToken).ConfigureAwait(false); + + if (needsTurnToken) + { + await runHandle.EnqueueMessageAsync(new TurnToken(emitEvents: true), cancellationToken).ConfigureAwait(false); + } + + return runHandle; + } + + /// + /// Helper method to detect if the starting executor of a given workflow accepts the provided input type as well as a TurnToken. + /// + private static async ValueTask StartingExecutorHandlesTurnTokenAsync(Workflow workflow) + { + if (workflow.Registrations.TryGetValue(workflow.StartExecutorId, out var registration)) + { + // Create instance to check type + Executor startExecutor = await registration.CreateInstanceAsync(string.Empty) + .ConfigureAwait(false); + return startExecutor.CanHandle(typeof(TInput)) && startExecutor.CanHandle(typeof(TurnToken)); + } + + return false; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs new file mode 100644 index 00000000000..bc0eb594638 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessExecutionOptions.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.InProc; + +internal class InProcessExecutionOptions +{ + public ExecutionMode ExecutionMode { get; init; } = InProcessExecution.Default.ExecutionMode; + + public bool AllowSharedWorkflow { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs index 6f4cef55c5d..9c100ecbbf2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunner.cs @@ -21,21 +21,46 @@ namespace Microsoft.Agents.AI.Workflows.InProc; /// scenarios where workflow execution does not require executor distribution. internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle { - public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? workflowOwnership = null, bool subworkflow = false, IEnumerable? knownValidInputTypes = null) + public static InProcessRunner CreateTopLevelRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, bool enableConcurrentRuns = false, IEnumerable? knownValidInputTypes = null) { + return new InProcessRunner(workflow, + checkpointManager, + runId, + enableConcurrentRuns: enableConcurrentRuns, + knownValidInputTypes: knownValidInputTypes); + } + + public static InProcessRunner CreateSubworkflowRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool enableConcurrentRuns = false, IEnumerable? knownValidInputTypes = null) + { + return new InProcessRunner(workflow, + checkpointManager, + runId, + existingOwnerSignoff: existingOwnerSignoff, + enableConcurrentRuns: enableConcurrentRuns, + knownValidInputTypes: knownValidInputTypes, + subworkflow: true); + } + + private InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, string? runId = null, object? existingOwnerSignoff = null, bool subworkflow = false, bool enableConcurrentRuns = false, IEnumerable? knownValidInputTypes = null) + { + if (enableConcurrentRuns && !workflow.AllowConcurrent) + { + throw new InvalidOperationException("Workflow must only consist of cross-run share-capable or factory-created executors. Executors " + + $"not supporting concurrent: {string.Join(", ", workflow.NonConcurrentExecutorIds)}"); + } + this.RunId = runId ?? Guid.NewGuid().ToString("N"); this.StartExecutorId = workflow.StartExecutorId; this.Workflow = Throw.IfNull(workflow); - this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, workflowOwnership, subworkflow); + this.RunContext = new InProcessRunnerContext(workflow, this.RunId, withCheckpointing: checkpointManager != null, this.OutgoingEvents, this.StepTracer, existingOwnerSignoff, subworkflow, enableConcurrentRuns); this.CheckpointManager = checkpointManager; this._knownValidInputTypes = knownValidInputTypes != null ? [.. knownValidInputTypes] : []; - // Initialize the runners for each of the edges, along with the state for edges that - // need it. + // Initialize the runners for each of the edges, along with the state for edges that need it. this.EdgeMap = new EdgeMap(this.RunContext, this.Workflow.Edges, this.Workflow.Ports.Values, this.Workflow.StartExecutorId, this.StepTracer); } @@ -46,14 +71,14 @@ public InProcessRunner(Workflow workflow, ICheckpointManager? checkpointManager, public string StartExecutorId { get; } private readonly HashSet _knownValidInputTypes; - public async ValueTask IsValidInputTypeAsync(Type messageType, CancellationToken cancellation = default) + public async ValueTask IsValidInputTypeAsync(Type messageType, CancellationToken cancellationToken = default) { if (this._knownValidInputTypes.Contains(messageType)) { return true; } - Executor startingExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId, tracer: null).ConfigureAwait(false); + Executor startingExecutor = await this.RunContext.EnsureExecutorAsync(this.Workflow.StartExecutorId, tracer: null, cancellationToken).ConfigureAwait(false); if (startingExecutor.CanHandle(messageType)) { this._knownValidInputTypes.Add(messageType); @@ -63,10 +88,10 @@ public async ValueTask IsValidInputTypeAsync(Type messageType, Cancellatio return false; } - public ValueTask IsValidInputTypeAsync(CancellationToken cancellation = default) - => this.IsValidInputTypeAsync(typeof(T), cancellation); + public ValueTask IsValidInputTypeAsync(CancellationToken cancellationToken = default) + => this.IsValidInputTypeAsync(typeof(T), cancellationToken); - public async ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellation = default) + public async ValueTask EnqueueMessageUntypedAsync(object message, Type declaredType, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(message); @@ -78,7 +103,7 @@ public async ValueTask EnqueueMessageUntypedAsync(object message, Type dec // Check that the type of the incoming message is compatible with the starting executor's // input type. - if (!await this.IsValidInputTypeAsync(declaredType, cancellation).ConfigureAwait(false)) + if (!await this.IsValidInputTypeAsync(declaredType, cancellationToken).ConfigureAwait(false)) { return false; } @@ -87,13 +112,13 @@ public async ValueTask EnqueueMessageUntypedAsync(object message, Type dec return true; } - public ValueTask EnqueueMessageAsync(T message, CancellationToken cancellation = default) - => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), typeof(T), cancellation); + public ValueTask EnqueueMessageAsync(T message, CancellationToken cancellationToken = default) + => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), typeof(T), cancellationToken); - public ValueTask EnqueueMessageAsync(object message, CancellationToken cancellation = default) - => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), message.GetType(), cancellation); + public ValueTask EnqueueMessageUntypedAsync(object message, CancellationToken cancellationToken = default) + => this.EnqueueMessageUntypedAsync(Throw.IfNull(message), message.GetType(), cancellationToken); - ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellation) + ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, CancellationToken cancellationToken) { // TODO: Check that there exists a corresponding input port? return this.RunContext.AddExternalResponseAsync(response); @@ -110,13 +135,13 @@ ValueTask ISuperStepRunner.EnqueueResponseAsync(ExternalResponse response, Cance private ValueTask RaiseWorkflowEventAsync(WorkflowEvent workflowEvent) => this.OutgoingEvents.EnqueueAsync(workflowEvent); - public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellation = default) + public ValueTask BeginStreamAsync(ExecutionMode mode, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); return new(new AsyncRunHandle(this, this, mode)); } - public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellation = default) + public async ValueTask ResumeStreamAsync(ExecutionMode mode, CheckpointInfo fromCheckpoint, CancellationToken cancellationToken = default) { this.RunContext.CheckEnded(); Throw.IfNull(fromCheckpoint); @@ -125,7 +150,7 @@ public async ValueTask ResumeStreamAsync(ExecutionMode mode, Che throw new InvalidOperationException("This runner was not configured with a CheckpointManager, so it cannot restore checkpoints."); } - await this.RestoreCheckpointAsync(fromCheckpoint, cancellation).ConfigureAwait(false); + await this.RestoreCheckpointAsync(fromCheckpoint, cancellationToken).ConfigureAwait(false); return new AsyncRunHandle(this, this, mode); } @@ -142,7 +167,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance return false; } - StepContext currentStep = await this.RunContext.AdvanceAsync().ConfigureAwait(false); + StepContext currentStep = await this.RunContext.AdvanceAsync(cancellationToken).ConfigureAwait(false); if (currentStep.HasMessages || this.RunContext.HasQueuedExternalDeliveries || @@ -150,7 +175,7 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance { try { - await this.RunSuperstepAsync(currentStep).ConfigureAwait(false); + await this.RunSuperstepAsync(currentStep, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { } @@ -165,9 +190,9 @@ async ValueTask ISuperStepRunner.RunSuperStepAsync(CancellationToken cance return false; } - private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue envelopes) + private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue envelopes, CancellationToken cancellationToken) { - Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer).ConfigureAwait(false); + Executor executor = await this.RunContext.EnsureExecutorAsync(receiverId, this.StepTracer, cancellationToken).ConfigureAwait(false); this.StepTracer.TraceActivated(receiverId); while (envelopes.TryDequeue(out var envelope)) @@ -175,19 +200,20 @@ private async ValueTask DeliverMessagesAsync(string receiverId, ConcurrentQueue< await executor.ExecuteAsync( envelope.Message, envelope.MessageType, - this.RunContext.Bind(receiverId, envelope.TraceContext) + this.RunContext.Bind(receiverId, envelope.TraceContext), + cancellationToken ).ConfigureAwait(false); } } - private async ValueTask RunSuperstepAsync(StepContext currentStep) + private async ValueTask RunSuperstepAsync(StepContext currentStep, CancellationToken cancellationToken) { await this.RaiseWorkflowEventAsync(this.StepTracer.Advance(currentStep)).ConfigureAwait(false); // Deliver the messages and queue the next step List receiverTasks = currentStep.QueuedMessages.Keys - .Select(receiverId => this.DeliverMessagesAsync(receiverId, currentStep.MessagesFor(receiverId)).AsTask()) + .Select(receiverId => this.DeliverMessagesAsync(receiverId, currentStep.MessagesFor(receiverId), cancellationToken).AsTask()) .ToList(); // TODO: Should we let the user specify that they want strictly turn-based execution of the edges, vs. concurrent? @@ -202,12 +228,12 @@ private async ValueTask RunSuperstepAsync(StepContext currentStep) List subworkflowTasks = new(); foreach (ISuperStepRunner subworkflowRunner in this.RunContext.JoinedSubworkflowRunners) { - subworkflowTasks.Add(subworkflowRunner.RunSuperStepAsync(CancellationToken.None).AsTask()); + subworkflowTasks.Add(subworkflowRunner.RunSuperStepAsync(cancellationToken).AsTask()); } await Task.WhenAll(subworkflowTasks).ConfigureAwait(false); - await this.CheckpointAsync().ConfigureAwait(false); + await this.CheckpointAsync(cancellationToken).ConfigureAwait(false); await this.RaiseWorkflowEventAsync(this.StepTracer.Complete(this.RunContext.NextStepHasActions, this.RunContext.HasUnservicedRequests)) .ConfigureAwait(false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs index cd3f763ae86..874464812ee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProc/InProcessRunnerContext.cs @@ -32,7 +32,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext private readonly ConcurrentDictionary> _executors = new(); private readonly ConcurrentQueue> _queuedExternalDeliveries = new(); - private readonly ConcurrentQueue _joinedSubworkflowRunners = new(); + private readonly ConcurrentDictionary _joinedSubworkflowRunners = new(); private readonly ConcurrentDictionary _externalRequests = new(); @@ -42,11 +42,19 @@ public InProcessRunnerContext( bool withCheckpointing, IEventSink outgoingEvents, IStepTracer? stepTracer, - object? workflowOwnership = null, + object? existingOwnershipSignoff = null, bool subworkflow = false, + bool enableConcurrentRuns = false, ILogger? logger = null) { - workflow.TakeOwnership(this, existingOwnershipSignoff: workflowOwnership); + if (enableConcurrentRuns) + { + workflow.CheckOwnership(existingOwnershipSignoff: existingOwnershipSignoff); + } + else + { + workflow.TakeOwnership(this, existingOwnershipSignoff: existingOwnershipSignoff); + } this._workflow = workflow; this._runId = runId; @@ -54,10 +62,11 @@ public InProcessRunnerContext( this._outputFilter = new(workflow); this.WithCheckpointing = withCheckpointing; + this.ConcurrentRunsEnabled = enableConcurrentRuns; this.OutgoingEvents = outgoingEvents; } - public async ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer) + public async ValueTask EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default) { this.CheckEnded(); Task executorTask = this._executors.GetOrAdd(executorId, CreateExecutorAsync); @@ -70,6 +79,9 @@ async Task CreateExecutorAsync(string id) } Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false); + await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken) + .ConfigureAwait(false); + tracer?.TraceActivated(executorId); if (executor is RequestInfoExecutor requestInputExecutor) @@ -88,9 +100,9 @@ async Task CreateExecutorAsync(string id) return await executorTask.ConfigureAwait(false); } - public async ValueTask> GetStartingExecutorInputTypesAsync(CancellationToken cancellation = default) + public async ValueTask> GetStartingExecutorInputTypesAsync(CancellationToken cancellationToken = default) { - Executor startingExecutor = await this.EnsureExecutorAsync(this._workflow.StartExecutorId, tracer: null) + Executor startingExecutor = await this.EnsureExecutorAsync(this._workflow.StartExecutorId, tracer: null, cancellationToken) .ConfigureAwait(false); return startingExecutor.InputTypes; @@ -138,14 +150,15 @@ await this._edgeMap.PrepareDeliveryForResponseAsync(response) } public bool HasQueuedExternalDeliveries => !this._queuedExternalDeliveries.IsEmpty; - public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnprocessedMessages); + public bool JoinedRunnersHaveActions => this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnprocessedMessages); + public bool NextStepHasActions => this._nextStep.HasMessages || this.HasQueuedExternalDeliveries || this.JoinedRunnersHaveActions; public bool HasUnservicedRequests => !this._externalRequests.IsEmpty || - this._joinedSubworkflowRunners.Any(joinedRunner => joinedRunner.HasUnservicedRequests); + this._joinedSubworkflowRunners.Values.Any(runner => runner.HasUnservicedRequests); - public async ValueTask AdvanceAsync() + public async ValueTask AdvanceAsync(CancellationToken cancellationToken = default) { this.CheckEnded(); @@ -159,7 +172,7 @@ public async ValueTask AdvanceAsync() return Interlocked.Exchange(ref this._nextStep, new StepContext()); } - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) { this.CheckEnded(); return this.OutgoingEvents.EnqueueAsync(workflowEvent); @@ -168,7 +181,7 @@ public ValueTask AddEventAsync(WorkflowEvent workflowEvent) private static readonly string s_namespace = typeof(IWorkflowContext).Namespace!; private static readonly ActivitySource s_activitySource = new(s_namespace); - public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null) + public async ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default) { using Activity? activity = s_activitySource.StartActivity(ActivityNames.MessageSend, ActivityKind.Producer); // Create a carrier for trace context propagation @@ -231,19 +244,19 @@ private sealed class BoundContext( OutputFilter outputFilter, Dictionary? traceContext) : IWorkflowContext { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => RunnerContext.AddEventAsync(workflowEvent); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => RunnerContext.AddEventAsync(workflowEvent, cancellationToken); - public ValueTask SendMessageAsync(object message, string? targetId = null) + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) { - return RunnerContext.SendMessageAsync(ExecutorId, message, targetId); + return RunnerContext.SendMessageAsync(ExecutorId, message, targetId, cancellationToken); } - public async ValueTask YieldOutputAsync(object output) + public async ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) { RunnerContext.CheckEnded(); Throw.IfNull(output); - Executor sourceExecutor = await RunnerContext.EnsureExecutorAsync(ExecutorId, tracer: null).ConfigureAwait(false); + Executor sourceExecutor = await RunnerContext.EnsureExecutorAsync(ExecutorId, tracer: null, cancellationToken).ConfigureAwait(false); if (!sourceExecutor.CanOutput(output.GetType())) { throw new InvalidOperationException($"Cannot output object of type {output.GetType().Name}. Expecting one of [{string.Join(", ", sourceExecutor.OutputTypes)}]."); @@ -251,30 +264,37 @@ public async ValueTask YieldOutputAsync(object output) if (outputFilter.CanOutput(ExecutorId, output)) { - await this.AddEventAsync(new WorkflowOutputEvent(output, ExecutorId)).ConfigureAwait(false); + await this.AddEventAsync(new WorkflowOutputEvent(output, ExecutorId), cancellationToken).ConfigureAwait(false); } } public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent()); - public ValueTask ReadStateAsync(string key, string? scopeName = null) + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => RunnerContext.StateManager.ReadStateAsync(ExecutorId, scopeName, key); - public ValueTask> ReadStateKeysAsync(string? scopeName = null) + [return: NotNull] + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + => RunnerContext.StateManager.ReadOrInitStateAsync(ExecutorId, scopeName, key, initialStateFactory); + + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => RunnerContext.StateManager.ReadKeysAsync(ExecutorId, scopeName); - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) => RunnerContext.StateManager.WriteStateAsync(ExecutorId, scopeName, key, value); - public ValueTask QueueClearScopeAsync(string? scopeName = null) + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) => RunnerContext.StateManager.ClearStateAsync(ExecutorId, scopeName); public IReadOnlyDictionary? TraceContext => traceContext; + + public bool ConcurrentRunsEnabled => RunnerContext.ConcurrentRunsEnabled; } public bool WithCheckpointing { get; } + public bool ConcurrentRunsEnabled { get; } - internal Task PrepareForCheckpointAsync(CancellationToken cancellation = default) + internal Task PrepareForCheckpointAsync(CancellationToken cancellationToken = default) { this.CheckEnded(); @@ -283,7 +303,7 @@ internal Task PrepareForCheckpointAsync(CancellationToken cancellation = default async Task InvokeCheckpointingAsync(Task executorTask) { Executor executor = await executorTask.ConfigureAwait(false); - await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellation).ConfigureAwait(false); + await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false); } } @@ -320,7 +340,7 @@ internal async ValueTask RepublishUnservicedRequestsAsync(CancellationToken canc { foreach (string requestId in this._externalRequests.Keys) { - await this.AddEventAsync(new RequestInfoEvent(this._externalRequests[requestId])) + await this.AddEventAsync(new RequestInfoEvent(this._externalRequests[requestId]), cancellationToken) .ConfigureAwait(false); } } @@ -380,23 +400,33 @@ public async ValueTask EndRunAsync() } } - await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false); + if (!this.ConcurrentRunsEnabled) + { + await this._workflow.ReleaseOwnershipAsync(this).ConfigureAwait(false); + } } } - public IEnumerable JoinedSubworkflowRunners => this._joinedSubworkflowRunners; + public IEnumerable JoinedSubworkflowRunners => this._joinedSubworkflowRunners.Values; - public ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation = default) + public ValueTask AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken = default) { // This needs to be a thread-safe ordered collection because we can potentially instantiate executors // in parallel, which means multiple sub-workflows could be attaching at the same time. - this._joinedSubworkflowRunners.Enqueue(superStepRunner); + string joinId; + do + { + joinId = Guid.NewGuid().ToString("N"); + } while (!this._joinedSubworkflowRunners.TryAdd(joinId, superStepRunner)); + return default; } - ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation) - => this.AddEventAsync(workflowEvent); + public ValueTask DetachSuperstepAsync(string joinId) => new(this._joinedSubworkflowRunners.TryRemove(joinId, out _)); + + ValueTask ISuperStepJoinContext.ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken) + => this.AddEventAsync(workflowEvent, cancellationToken); - ValueTask ISuperStepJoinContext.SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellation) - => this.SendMessageAsync(senderId, Throw.IfNull(message)); + ValueTask ISuperStepJoinContext.SendMessageAsync(string senderId, [DisallowNull] TMessage message, CancellationToken cancellationToken) + => this.SendMessageAsync(senderId, Throw.IfNull(message), cancellationToken: cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs index 831d4f5b29b..7f736e58f34 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/InProcessExecution.cs @@ -23,6 +23,11 @@ public static class InProcessExecution /// public static InProcessExecutionEnvironment OffThread { get; } = new(ExecutionMode.OffThread); + /// + /// Gets an execution environment that enables concurrent, off-thread in-process execution. + /// + public static InProcessExecutionEnvironment Concurrent { get; } = new(ExecutionMode.OffThread, enableConcurrentRuns: true); + /// /// An InProcesExecution environment which will run SuperSteps in the event watching thread, /// accumulating events during each SuperStep and streaming them out after each SuperStep is diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs index 38865da089a..5110294171b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/PortableValue.cs @@ -15,7 +15,11 @@ namespace Microsoft.Agents.AI.Workflows; /// public sealed class PortableValue { - internal PortableValue(object value) + /// + /// Initializes a new instance . + /// + /// The represented value. + public PortableValue(object value) { this._value = value; this.TypeId = new(value.GetType()); @@ -114,10 +118,7 @@ public override int GetHashCode() /// true if the current value can be represented as type TValue; otherwise, false. public bool Is([NotNullWhen(true)] out TValue? value) { - if (this.Value is IDelayedDeserialization delayedDeserialization) - { - this._deserializedValueCache ??= delayedDeserialization.Deserialize(); - } + this.TryDeserializeAndUpdateCache(typeof(TValue), out _); if (this.Value is TValue typedValue) { @@ -152,11 +153,9 @@ public bool Is([NotNullWhen(true)] out TValue? value) /// true if the current instance can be assigned to targetType; otherwise, false. public bool IsType(Type targetType, [NotNullWhen(true)] out object? value) { + // Unfortunately, there is no way to check that the TypeId specified is assignable to the provided type Throw.IfNull(targetType); - if (this.Value is IDelayedDeserialization delayedDeserialization) - { - this._deserializedValueCache ??= delayedDeserialization.Deserialize(targetType); - } + this.TryDeserializeAndUpdateCache(targetType, out _); if (this.Value is not null && targetType.IsInstanceOfType(this.Value)) { @@ -167,4 +166,41 @@ public bool IsType(Type targetType, [NotNullWhen(true)] out object? value) value = null; return false; } + + private bool TryDeserializeAndUpdateCache(Type targetType, out object? replacedCacheValueOrNull) + { + replacedCacheValueOrNull = null; + + // Explicitly use _value here since we do not want to be overridden by the cache, if any + if (this._value is not IDelayedDeserialization delayedDeserialization) + { + // Not a delayed deserialization; nothing to do + return false; + } + + bool isCompatibleType = false; + if (this._deserializedValueCache == null || !(isCompatibleType = targetType.IsAssignableFrom(this._deserializedValueCache.GetType()))) + { + // Either we have no cache, or the types are incompatible; see if we can deserialize + try + { + object? deserialized = delayedDeserialization.Deserialize(targetType); + + if (deserialized != null && targetType.IsInstanceOfType(deserialized)) + { + replacedCacheValueOrNull = this._deserializedValueCache; + this._deserializedValueCache = deserialized; + + return true; + } + } + catch + { + isCompatibleType = false; + } + } + + // The last possibility is that we already deserialized successfully + return isCompatibleType; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs index 1903e49d443..3b18379907f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Workflows.Reflection; @@ -15,8 +16,10 @@ public interface IMessageHandler /// /// The message to handle. /// The execution context. + /// The to monitor for cancellation requests. + /// The default is . /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IWorkflowContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken = default); } /// @@ -32,6 +35,8 @@ public interface IMessageHandler /// /// The message to handle. /// The execution context. + /// The to monitor for cancellation requests. + /// The default is . /// A task that represents the asynchronous operation. - ValueTask HandleAsync(TMessage message, IWorkflowContext context); + ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken = default); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs index e00675c4738..f63a43b4a85 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Execution; @@ -26,14 +27,19 @@ public MessageHandlerInfo(MethodInfo handlerInfo) this.HandlerInfo = handlerInfo; ParameterInfo[] parameters = handlerInfo.GetParameters(); - if (parameters.Length != 2) + if (parameters.Length != 3) { - throw new ArgumentException("Handler method must have exactly two parameters: TMessage and IExecutionContext.", nameof(handlerInfo)); + throw new ArgumentException("Handler method must have exactly three parameters: TMessage, IWorkflowContext, and CancellationToken.", nameof(handlerInfo)); } if (parameters[1].ParameterType != typeof(IWorkflowContext)) { - throw new ArgumentException("Handler method's second parameter must be of type IExecutionContext.", nameof(handlerInfo)); + throw new ArgumentException("Handler method's second parameter must be of type IWorkflowContext.", nameof(handlerInfo)); + } + + if (parameters[2].ParameterType != typeof(CancellationToken)) + { + throw new ArgumentException("Handler method's third parameter must be of type CancellationToken.", nameof(handlerInfo)); } this.InType = parameters[0].ParameterType; @@ -61,17 +67,17 @@ public MessageHandlerInfo(MethodInfo handlerInfo) } } - public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) + public static Func> Bind(Func handlerAsync, bool checkType, Type? resultType = null, Func>? unwrapper = null) { return InvokeHandlerAsync; - async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext) + async ValueTask InvokeHandlerAsync(object message, IWorkflowContext workflowContext, CancellationToken cancellationToken) { bool expectingVoid = resultType is null || resultType == typeof(void); try { - object? maybeValueTask = handlerAsync(message, workflowContext); + object? maybeValueTask = handlerAsync(message, workflowContext, cancellationToken); if (expectingVoid) { @@ -109,6 +115,11 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext return CallResult.ReturnResult(result); } + catch (OperationCanceledException) + { + // If the operation was canceled, return a canceled CallResult. + return CallResult.Cancelled(wasVoid: expectingVoid); + } catch (Exception ex) { // If the handler throws an exception, return it in the CallResult. @@ -117,7 +128,7 @@ async ValueTask InvokeHandlerAsync(object message, IWorkflowContext } } - public Func> Bind< + public Func> Bind< [DynamicallyAccessedMembers( ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) ] TExecutor @@ -128,9 +139,9 @@ ] TExecutor MethodInfo handlerMethod = this.HandlerInfo; return Bind(InvokeHandler, checkType, this.OutType, this.Unwrapper); - object? InvokeHandler(object message, IWorkflowContext workflowContext) + object? InvokeHandler(object message, IWorkflowContext workflowContext, CancellationToken cancellationToken) { - return handlerMethod.Invoke(executor, [message, workflowContext]); + return handlerMethod.Invoke(executor, [message, workflowContext, cancellationToken]); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index 61958cfbf98..d96f9319f4f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -16,8 +16,9 @@ public class ReflectingExecutor< ] TExecutor > : Executor where TExecutor : ReflectingExecutor { - /// - protected ReflectingExecutor(string id, ExecutorOptions? options = null) : base(id, options) + /// + protected ReflectingExecutor(string id, ExecutorOptions? options = null, bool declareCrossRunShareable = false) + : base(id, options, declareCrossRunShareable) { } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs new file mode 100644 index 00000000000..8f11fe7ed6e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RoundRobinGroupChatManager.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides a that selects agents in a round-robin fashion. +/// +public class RoundRobinGroupChatManager : GroupChatManager +{ + private readonly IReadOnlyList _agents; + private readonly Func, CancellationToken, ValueTask>? _shouldTerminateFunc; + private int _nextIndex; + + /// + /// Initializes a new instance of the class. + /// + /// The agents to be managed as part of this workflow. + /// + /// An optional function that determines whether the group chat should terminate based on the chat history + /// before factoring in the default behavior, which is to terminate based only on the iteration count. + /// + public RoundRobinGroupChatManager( + IReadOnlyList agents, + Func, CancellationToken, ValueTask>? shouldTerminateFunc = null) + { + Throw.IfNullOrEmpty(agents); + foreach (var agent in agents) + { + Throw.IfNull(agent, nameof(agents)); + } + + this._agents = agents; + this._shouldTerminateFunc = shouldTerminateFunc; + } + + /// + protected internal override ValueTask SelectNextAgentAsync( + IReadOnlyList history, CancellationToken cancellationToken = default) + { + AIAgent nextAgent = this._agents[this._nextIndex]; + + this._nextIndex = (this._nextIndex + 1) % this._agents.Count; + + return new ValueTask(nextAgent); + } + + /// + protected internal override async ValueTask ShouldTerminateAsync( + IReadOnlyList history, CancellationToken cancellationToken = default) + { + if (this._shouldTerminateFunc is { } func && await func(this, history, cancellationToken).ConfigureAwait(false)) + { + return true; + } + + return await base.ShouldTerminateAsync(history, cancellationToken).ConfigureAwait(false); + } + + /// + protected internal override void Reset() + { + base.Reset(); + this._nextIndex = 0; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs index 965de0cdaa9..99cfdb69927 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/RouteBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Shared.Diagnostics; @@ -10,12 +11,14 @@ System.Func< Microsoft.Agents.AI.Workflows.PortableValue, // message Microsoft.Agents.AI.Workflows.IWorkflowContext, // context + System.Threading.CancellationToken, // cancellation System.Threading.Tasks.ValueTask >; using MessageHandlerF = System.Func< object, // message Microsoft.Agents.AI.Workflows.IWorkflowContext, // context + System.Threading.CancellationToken, // cancellation System.Threading.Tasks.ValueTask >; @@ -73,32 +76,58 @@ internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handl return this; } - internal RouteBuilder AddHandlerUntyped(Type type, Func handler, bool overwrite = false) + internal RouteBuilder AddHandlerUntyped(Type type, Func handler, bool overwrite = false) { Throw.IfNull(handler); return this.AddHandlerInternal(type, WrappedHandlerAsync, outputType: null, overwrite); - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - await handler.Invoke(msg, ctx).ConfigureAwait(false); + await handler.Invoke(message, context, cancellationToken).ConfigureAwait(false); return CallResult.ReturnVoid(); } } - internal RouteBuilder AddHandlerUntyped(Type type, Func> handler, bool overwrite = false) + internal RouteBuilder AddHandlerUntyped(Type type, Func> handler, bool overwrite = false) { Throw.IfNull(handler); return this.AddHandlerInternal(type, WrappedHandlerAsync, outputType: typeof(TResult), overwrite); - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - TResult result = await handler.Invoke(msg, ctx).ConfigureAwait(false); + TResult result = await handler.Invoke(message, context, cancellationToken).ConfigureAwait(false); return CallResult.ReturnResult(result); } } + /// + /// Registers a handler for messages of the specified input type in the workflow route. + /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. + /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. + public RouteBuilder AddHandler(Action handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); + + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) + { + handler.Invoke((TInput)message, context, cancellationToken); + return CallResult.ReturnVoid(); + } + } + /// /// Registers a handler for messages of the specified input type in the workflow route. /// @@ -118,9 +147,35 @@ public RouteBuilder AddHandler(Action handler, return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) + { + handler.Invoke((TInput)message, context); + return CallResult.ReturnVoid(); + } + } + + /// + /// Registers a handler for messages of the specified input type in the workflow route. + /// + /// If a handler for the specified input type already exists and is + /// , the existing handler will not be replaced. Handlers are invoked asynchronously and are + /// expected to complete their processing before the workflow continues. + /// + /// A delegate that processes messages of type within the workflow context. The + /// delegate is invoked for each incoming message of the specified type. + /// to replace any existing handler for the specified input type; otherwise, to preserve the existing handler. + /// The current instance, enabling fluent configuration of additional handlers or route + /// options. + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); + + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - handler.Invoke((TInput)msg, ctx); + await handler.Invoke((TInput)message, context, cancellationToken).ConfigureAwait(false); return CallResult.ReturnVoid(); } } @@ -144,13 +199,39 @@ public RouteBuilder AddHandler(Func return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: null, overwrite); - async ValueTask WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + await handler.Invoke((TInput)message, context).ConfigureAwait(false); return CallResult.ReturnVoid(); } } + /// + /// Registers a handler function for messages of the specified input type in the workflow route. + /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddHandler(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: typeof(TResult), overwrite); + + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) + { + TResult result = handler.Invoke((TInput)message, context, cancellationToken); + return CallResult.ReturnResult(result); + } + } + /// /// Registers a handler function for messages of the specified input type in the workflow route. /// @@ -170,9 +251,35 @@ public RouteBuilder AddHandler(Func WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) + { + TResult result = handler.Invoke((TInput)message, context); + return CallResult.ReturnResult(result); + } + } + + /// + /// Registers a handler function for messages of the specified input type in the workflow route. + /// + /// If a handler for the given input type already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler + /// receives the input message and workflow context, and returns a result asynchronously. + /// The type of input message the handler will process. + /// The type of result produced by the handler. + /// A function that processes messages of type within the workflow context and returns + /// a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddHandler(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddHandlerInternal(typeof(TInput), WrappedHandlerAsync, outputType: typeof(TResult), overwrite); + + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - TResult result = handler.Invoke((TInput)msg, ctx); + TResult result = await handler.Invoke((TInput)message, context, cancellationToken).ConfigureAwait(false); return CallResult.ReturnResult(result); } } @@ -196,9 +303,9 @@ public RouteBuilder AddHandler(Func WrappedHandlerAsync(object msg, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken) { - TResult result = await handler.Invoke((TInput)msg, ctx).ConfigureAwait(false); + TResult result = await handler.Invoke((TInput)message, context).ConfigureAwait(false); return CallResult.ReturnResult(result); } } @@ -215,6 +322,30 @@ private RouteBuilder AddCatchAll(CatchAllF handler, bool overwrite = false) return this; } + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context. The delegate is invoked for each incoming message not otherwise handled. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) + { + await handler.Invoke(message, context, cancellationToken).ConfigureAwait(false); + return CallResult.ReturnVoid(); + } + } + /// /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. /// @@ -232,13 +363,37 @@ public RouteBuilder AddCatchAll(Func return this.AddCatchAll(WrappedHandlerAsync, overwrite); - async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) { - await handler.Invoke(message, ctx).ConfigureAwait(false); + await handler.Invoke(message, context).ConfigureAwait(false); return CallResult.ReturnVoid(); } } + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context and returns a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func> handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) + { + TResult result = await handler.Invoke(message, context, cancellationToken).ConfigureAwait(false); + return CallResult.ReturnResult(result); + } + } + /// /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. /// @@ -256,13 +411,37 @@ public RouteBuilder AddCatchAll(Func WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + async ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) { - TResult result = await handler.Invoke(message, ctx).ConfigureAwait(false); + TResult result = await handler.Invoke(message, context).ConfigureAwait(false); return CallResult.ReturnResult(result); } } + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context. The delegate is invoked for each incoming message not otherwise handled. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Action handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx, CancellationToken cancellationToken) + { + handler.Invoke(message, ctx, cancellationToken); + return new(CallResult.ReturnVoid()); + } + } + /// /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. /// @@ -280,13 +459,37 @@ public RouteBuilder AddCatchAll(Action handler, return this.AddCatchAll(WrappedHandlerAsync, overwrite); - ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx, CancellationToken cancellationToken) { handler.Invoke(message, ctx); return new(CallResult.ReturnVoid()); } } + /// + /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. + /// + /// If a catch-all handler for already exists, setting to + /// will replace the existing handler; otherwise, an exception may be thrown. The handler receives the input message + /// wrapped as and workflow context, and returns a result asynchronously. + /// A function that processes messages wrapped as within the + /// workflow context and returns a representing the asynchronous result. + /// to replace any existing handler for the input type; otherwise, to + /// preserve existing handlers. + /// The current instance, enabling fluent configuration of workflow routes. + public RouteBuilder AddCatchAll(Func handler, bool overwrite = false) + { + Throw.IfNull(handler); + + return this.AddCatchAll(WrappedHandlerAsync, overwrite); + + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) + { + TResult result = handler.Invoke(message, context, cancellationToken); + return new(CallResult.ReturnResult(result)); + } + } + /// /// Register a handler function as a catch-all handler: It will be used if not type-matching handler is registered. /// @@ -304,9 +507,9 @@ public RouteBuilder AddCatchAll(Func WrappedHandlerAsync(PortableValue message, IWorkflowContext ctx) + ValueTask WrappedHandlerAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) { - TResult result = handler.Invoke(message, ctx); + TResult result = handler.Invoke(message, context); return new(CallResult.ReturnResult(result)); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs index dec02fbd463..3dfa4f271ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Run.cs @@ -43,8 +43,8 @@ internal async ValueTask RunToNextHaltAsync(CancellationToken cancellation /// /// Gets the current execution status of the workflow run. /// - public ValueTask GetStatusAsync(CancellationToken cancellation = default) - => this._runHandle.GetStatusAsync(cancellation); + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) + => this._runHandle.GetStatusAsync(cancellationToken); /// /// Gets all events emitted by the workflow. @@ -113,7 +113,7 @@ public async ValueTask ResumeAsync(CancellationToken cancellationToken { foreach (object? message in messages) { - await this._runHandle.EnqueueMessageUntypedAsync(message, cancellation: cancellationToken).ConfigureAwait(false); + await this._runHandle.EnqueueMessageUntypedAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); } } else diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index e50e0f803a5..836399c5c1d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -30,7 +30,7 @@ protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContex if (this._thread is not null) { JsonElement threadValue = this._thread.Serialize(); - threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue).AsTask(); + threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue, cancellationToken: cancellationToken).AsTask(); } Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); @@ -40,7 +40,7 @@ protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContex protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - JsonElement? threadValue = await context.ReadStateAsync(ThreadStateKey).ConfigureAwait(false); + JsonElement? threadValue = await context.ReadStateAsync(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false); if (threadValue.HasValue) { this._thread = this._agent.DeserializeThread(threadValue.Value); @@ -67,7 +67,7 @@ protected override async ValueTask TakeTurnAsync(List messages, IWo if (emitEvents ?? this._emitEvents) { - await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update)).ConfigureAwait(false); + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); } // TODO: FunctionCall request handling, and user info request handling. @@ -100,7 +100,7 @@ async ValueTask PublishCurrentMessageAsync() currentStreamingMessage.Contents = updates; updates = []; - await context.SendMessageAsync(currentStreamingMessage).ConfigureAwait(false); + await context.SendMessageAsync(currentStreamingMessage, cancellationToken: cancellationToken).ConfigureAwait(false); } currentStreamingMessage = null; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs new file mode 100644 index 00000000000..ea80f646f06 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AgentRunStreamingExecutor.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// +/// Executor that runs the agent and forwards all messages, input and output, to the next executor. +/// +internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput) + : ChatProtocolExecutor(agent.GetDescriptiveId(), DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +{ + private static ChatProtocolExecutorOptions DefaultOptions => new() + { + StringMessageChatRole = ChatRole.User + }; + + protected override async ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + { + List? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.DisplayName); + + List updates = []; + await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + if (emitEvents is true) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } + } + + roleChanged.ResetUserToAssistantForChangedRoles(); + + List result = includeInputInOutput ? [.. messages] : []; + result.AddRange(updates.ToAgentRunResponse().Messages); + + await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + public new ValueTask ResetAsync() => base.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs new file mode 100644 index 00000000000..b395dd4216d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ChatForwardingExecutor.cs @@ -0,0 +1,20 @@ +// 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/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs new file mode 100644 index 00000000000..8653dfbab14 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/CollectChatMessagesExecutor.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// +/// Provides an executor that batches received chat messages that it then releases when +/// receiving a . +/// +internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id), IResettableExecutor +{ + /// + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.SendMessageAsync(messages, cancellationToken: cancellationToken); + + ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs new file mode 100644 index 00000000000..7f509ef9fed --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/ConcurrentEndExecutor.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// +/// Provides an executor that accepts the output messages from each of the concurrent agents +/// and produces a result list containing the last message from each. +/// +internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor +{ + private readonly int _expectedInputs; + private readonly Func>, List> _aggregator; + private List> _allResults; + private int _remaining; + + public ConcurrentEndExecutor(int expectedInputs, Func>, List> aggregator) : base("ConcurrentEnd") + { + this._expectedInputs = expectedInputs; + this._aggregator = Throw.IfNull(aggregator); + + this._allResults = new List>(expectedInputs); + this._remaining = expectedInputs; + } + + private void Reset() + { + this._allResults = new List>(this._expectedInputs); + this._remaining = this._expectedInputs; + } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler>(async (messages, context, cancellationToken) => + { + // TODO: https://github.com/microsoft/agent-framework/issues/784 + // This locking should not be necessary. + bool done; + lock (this._allResults) + { + this._allResults.Add(messages); + done = --this._remaining == 0; + } + + if (done) + { + this._remaining = this._expectedInputs; + + var results = this._allResults; + this._allResults = new List>(this._expectedInputs); + await context.YieldOutputAsync(this._aggregator(results), cancellationToken).ConfigureAwait(false); + } + }); + + public ValueTask ResetAsync() + { + this.Reset(); + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs new file mode 100644 index 00000000000..16f749d5a37 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/GroupChatHost.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed class GroupChatHost( + string id, + AIAgent[] agents, + Dictionary agentMap, + Func, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor +{ + private readonly AIAgent[] _agents = agents; + private readonly Dictionary _agentMap = agentMap; + private readonly Func, GroupChatManager> _managerFactory = managerFactory; + private readonly List _pendingMessages = []; + + private GroupChatManager? _manager; + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder + .AddHandler((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message))) + .AddHandler((message, context, _) => this._pendingMessages.Add(message)) + .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) + .AddHandler((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed + .AddHandler(async (token, context, cancellationToken) => + { + List messages = [.. this._pendingMessages]; + this._pendingMessages.Clear(); + + this._manager ??= this._managerFactory(this._agents); + + if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false)) + { + var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false); + messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered]; + + if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent && + this._agentMap.TryGetValue(nextAgent, out var executor)) + { + this._manager.IterationCount++; + await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false); + await context.SendMessageAsync(token, executor.Id, cancellationToken).ConfigureAwait(false); + return; + } + } + + this._manager = null; + await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false); + }); + + public ValueTask ResetAsync() + { + this._pendingMessages.Clear(); + this._manager = null; + + return default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs new file mode 100644 index 00000000000..53f8fe3cfaa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Executor used to represent an agent in a handoffs workflow, responding to events. +internal sealed class HandoffAgentExecutor( + AIAgent agent, + string? handoffInstructions) : Executor(agent.GetDescriptiveId()), IResettableExecutor +{ + private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( + ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; + + private readonly AIAgent _agent = agent; + private readonly HashSet _handoffFunctionNames = []; + private ChatClientAgentRunOptions? _agentOptions; + + public void Initialize( + WorkflowBuilder builder, + Executor end, + Dictionary executors, + HashSet handoffs) => + builder.AddSwitch(this, sb => + { + if (handoffs.Count != 0) + { + Debug.Assert(this._agentOptions is null); + this._agentOptions = new() + { + ChatOptions = new() + { + AllowMultipleToolCalls = false, + Instructions = handoffInstructions, + Tools = [], + }, + }; + + foreach (HandoffTarget handoff in handoffs) + { + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{handoff.Target.GetDescriptiveId()}", handoff.Reason, s_handoffSchema); + + this._handoffFunctionNames.Add(handoffFunc.Name); + + this._agentOptions.ChatOptions.Tools.Add(handoffFunc); + + sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); + } + } + + sb.WithDefault(end); + }); + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(async (handoffState, context, cancellationToken) => + { + string? requestedHandoff = null; + List updates = []; + List allMessages = handoffState.Messages; + + List? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName); + + await foreach (var update in this._agent.RunStreamingAsync(allMessages, + options: this._agentOptions, + cancellationToken: cancellationToken) + .ConfigureAwait(false)) + { + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); + + foreach (var c in update.Contents) + { + if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name)) + { + requestedHandoff = fcc.Name; + await AddUpdateAsync( + new AgentRunResponseUpdate + { + AgentId = this._agent.Id, + AuthorName = this._agent.DisplayName, + Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }, + cancellationToken + ) + .ConfigureAwait(false); + } + } + } + + allMessages.AddRange(updates.ToAgentRunResponse().Messages); + + roleChanges.ResetUserToAssistantForChangedRoles(); + + await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false); + + async Task AddUpdateAsync(AgentRunResponseUpdate update, CancellationToken cancellationToken) + { + updates.Add(update); + if (handoffState.TurnToken.EmitEvents is true) + { + await context.AddEventAsync(new AgentRunUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false); + } + } + }); + + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs new file mode 100644 index 00000000000..cc4d87d21a6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed record class HandoffState( + TurnToken TurnToken, + string? InvokedHandoff, + List Messages); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs new file mode 100644 index 00000000000..0abe238133f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffTarget.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Describes a handoff to a specific target . +internal readonly record struct HandoffTarget(AIAgent Target, string? Reason = null) +{ + public bool Equals(HandoffTarget other) => this.Target.Id == other.Target.Id; + public override int GetHashCode() => this.Target.Id.GetHashCode(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs new file mode 100644 index 00000000000..1d825be6656 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Executor used at the end of a handoff workflow to raise a final completed event. +internal sealed class HandoffsEndExecutor() : Executor("HandoffEnd"), IResettableExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler((handoff, context, cancellationToken) => + context.YieldOutputAsync(handoff.Messages, cancellationToken)); + + public ValueTask ResetAsync() => default; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs new file mode 100644 index 00000000000..e7f6789edf8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +/// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. +internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor("HandoffStart", DefaultOptions), IResettableExecutor +{ + private static ChatProtocolExecutorOptions DefaultOptions => new() + { + StringMessageChatRole = ChatRole.User + }; + + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken); + + public new ValueTask ResetAsync() => base.ResetAsync(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs new file mode 100644 index 00000000000..a9d1005c0d2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/OutputMessagesExecutor.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows; + +public static partial class AgentWorkflowBuilder +{ + /// + /// Provides an executor that batches received chat messages that it then publishes as the final result + /// when receiving a . + /// + internal sealed class OutputMessagesExecutor() : ChatProtocolExecutor("OutputMessages"), IResettableExecutor + { + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.YieldOutputAsync(messages, cancellationToken); + + ValueTask IResettableExecutor.ResetAsync() => default; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs index 6226b00db9c..afb07507f98 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/RequestInfoExecutor.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Shared.Diagnostics; @@ -55,7 +56,7 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink); - public async ValueTask HandleCatchAllAsync(PortableValue message, IWorkflowContext context) + public async ValueTask HandleCatchAllAsync(PortableValue message, IWorkflowContext context, CancellationToken cancellationToken) { Throw.IfNull(message); @@ -70,13 +71,13 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) } else if (message.Is(out ExternalRequest? request)) { - return await this.HandleAsync(request, context).ConfigureAwait(false); + return await this.HandleAsync(request, context, cancellationToken).ConfigureAwait(false); } return null; } - public async ValueTask HandleAsync(ExternalRequest message, IWorkflowContext context) + public async ValueTask HandleAsync(ExternalRequest message, IWorkflowContext context, CancellationToken cancellationToken = default) { Debug.Assert(this._allowWrapped); Throw.IfNull(message); @@ -100,7 +101,7 @@ public async ValueTask HandleAsync(ExternalRequest message, IWo return request; } - public async ValueTask HandleAsync(object message, IWorkflowContext context) + public async ValueTask HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default) { Throw.IfNull(message); Debug.Assert(this.Port.Request.IsInstanceOfType(message)); @@ -111,7 +112,7 @@ public async ValueTask HandleAsync(object message, IWorkflowCon return request; } - public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context) + public async ValueTask HandleAsync(ExternalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default) { Throw.IfNull(message); Throw.IfNull(message.Data); @@ -127,14 +128,14 @@ public async ValueTask HandleAsync(object message, IWorkflowCon if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest)) { - await context.SendMessageAsync(originalRequest.RewrapResponse(message)).ConfigureAwait(false); + await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false); } else { - await context.SendMessageAsync(message).ConfigureAwait(false); + await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false); } - await context.SendMessageAsync(data).ConfigureAwait(false); + await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false); return message; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs index 78f79148e74..409f7511074 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/WorkflowHostExecutor.cs @@ -24,6 +24,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable private readonly ExecutorOptions _options; private ISuperStepJoinContext? _joinContext; + private string? _joinId; private StreamingRun? _run; [MemberNotNullWhen(true, nameof(_checkpointManager))] @@ -44,22 +45,22 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) return routeBuilder.AddCatchAll(this.QueueExternalMessageAsync); } - private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context) + private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken) { if (portableValue.Is(out ExternalResponse? response)) { response = this.CheckAndUnqualifyResponse(response); - await this.EnsureRunSendMessageAsync(response).ConfigureAwait(false); + await this.EnsureRunSendMessageAsync(response, cancellationToken: cancellationToken).ConfigureAwait(false); } else { InProcessRunner runner = await this.EnsureRunnerAsync().ConfigureAwait(false); - IEnumerable validInputTypes = await runner.RunContext.GetStartingExecutorInputTypesAsync().ConfigureAwait(false); + IEnumerable validInputTypes = await runner.RunContext.GetStartingExecutorInputTypesAsync(cancellationToken).ConfigureAwait(false); foreach (Type candidateType in validInputTypes) { if (portableValue.IsType(candidateType, out object? message)) { - await this.EnsureRunSendMessageAsync(message, candidateType).ConfigureAwait(false); + await this.EnsureRunSendMessageAsync(message, candidateType, cancellationToken: cancellationToken).ConfigureAwait(false); return; } } @@ -81,13 +82,17 @@ internal async ValueTask EnsureRunnerAsync() this._checkpointManager = new InMemoryCheckpointManager(); } - this._activeRunner = new(this._workflow, this._checkpointManager, this._runId, this._ownershipToken, subworkflow: true); + this._activeRunner = InProcessRunner.CreateSubworkflowRunner(this._workflow, + this._checkpointManager, + this._runId, + this._ownershipToken, + this.JoinContext.ConcurrentRunsEnabled); } return this._activeRunner; } - internal async ValueTask EnsureRunSendMessageAsync(object? incomingMessage = null, Type? incomingMessageType = null, bool resume = false, CancellationToken cancellation = default) + internal async ValueTask EnsureRunSendMessageAsync(object? incomingMessage = null, Type? incomingMessageType = null, bool resume = false, CancellationToken cancellationToken = default) { Debug.Assert(this._joinContext != null, "Must attach to a join context before starting the run."); @@ -114,20 +119,20 @@ internal async ValueTask EnsureRunSendMessageAsync(object? incomin throw new InvalidOperationException("No checkpoints available to resume from."); } - runHandle = await activeRunner.ResumeStreamAsync(ExecutionMode.Subworkflow, lastCheckpoint!, cancellation) + runHandle = await activeRunner.ResumeStreamAsync(ExecutionMode.Subworkflow, lastCheckpoint!, cancellationToken) .ConfigureAwait(false); if (incomingMessage != null) { - await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellation).ConfigureAwait(false); + await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellationToken).ConfigureAwait(false); } } else if (incomingMessage != null) { - runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellation) + runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken) .ConfigureAwait(false); - await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellation).ConfigureAwait(false); + await runHandle.EnqueueUntypedAndRunAsync(incomingMessage, cancellationToken).ConfigureAwait(false); } else { @@ -136,14 +141,14 @@ internal async ValueTask EnsureRunSendMessageAsync(object? incomin } else { - runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellation).ConfigureAwait(false); + runHandle = await activeRunner.BeginStreamAsync(ExecutionMode.Subworkflow, cancellationToken).ConfigureAwait(false); - await runHandle.EnqueueMessageUntypedAsync(Throw.IfNull(incomingMessage), cancellation: cancellation).ConfigureAwait(false); + await runHandle.EnqueueMessageUntypedAsync(Throw.IfNull(incomingMessage), cancellationToken: cancellationToken).ConfigureAwait(false); } this._run = new(runHandle); - await this._joinContext.AttachSuperstepAsync(activeRunner, cancellation).ConfigureAwait(false); + this._joinId = await this._joinContext.AttachSuperstepAsync(activeRunner, cancellationToken).ConfigureAwait(false); activeRunner.OutgoingEvents.EventRaised += this.ForwardWorkflowEventAsync; return this._run; @@ -228,7 +233,7 @@ internal async ValueTask AttachSuperStepContextAsync(ISuperStepJoinContext joinC protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { - await context.QueueStateUpdateAsync(nameof(CheckpointManager), this._checkpointManager).ConfigureAwait(false); + await context.QueueStateUpdateAsync(nameof(CheckpointManager), this._checkpointManager, cancellationToken: cancellationToken).ConfigureAwait(false); await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false); } @@ -237,7 +242,7 @@ protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowC { await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); - InMemoryCheckpointManager manager = await context.ReadStateAsync(nameof(InMemoryCheckpointManager)).ConfigureAwait(false) ?? new(); + InMemoryCheckpointManager manager = await context.ReadStateAsync(nameof(InMemoryCheckpointManager), cancellationToken: cancellationToken).ConfigureAwait(false) ?? new(); if (this._checkpointManager == manager) { // We are restoring in the context of the same run; not need to rebuild the entire execution stack. @@ -249,7 +254,7 @@ protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowC await this.ResetAsync().ConfigureAwait(false); } - StreamingRun run = await this.EnsureRunSendMessageAsync(cancellation: cancellationToken).ConfigureAwait(false); + StreamingRun run = await this.EnsureRunSendMessageAsync(cancellationToken: cancellationToken).ConfigureAwait(false); } private async ValueTask ResetAsync() @@ -265,7 +270,18 @@ private async ValueTask ResetAsync() this._activeRunner.OutgoingEvents.EventRaised -= this.ForwardWorkflowEventAsync; await this._activeRunner.RequestEndRunAsync().ConfigureAwait(false); - this._activeRunner = new(this._workflow, this._checkpointManager, this._runId); + this._activeRunner = null; + } + + if (this._joinContext != null) + { + if (this._joinId != null) + { + await this._joinContext.DetachSuperstepAsync(this._joinId).ConfigureAwait(false); + this._joinId = null; + } + + this._joinContext = null; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs new file mode 100644 index 00000000000..344134369df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Reflection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Provides a base class for executors that maintain and manage state across multiple message handling operations. +/// +/// The type of state associated with this Executor. +public abstract class StatefulExecutor : Executor +{ + private readonly Func _initialStateFactory; + + private TState? _stateCache; + + /// + /// Initializes the executor with a unique id and an initial value for the state. + /// + /// The unique identifier for this executor instance. Cannot be null or empty. + /// A factory to initialize the state value to be used by the executor. + /// Optional configuration settings for the executor. If null, default options are used. + /// true to declare that the executor's state can be shared across multiple runs; otherwise, false. + protected StatefulExecutor(string id, + Func initialStateFactory, + StatefulExecutorOptions? options = null, + bool declareCrossRunShareable = false) + : base(id, options ?? new StatefulExecutorOptions(), declareCrossRunShareable) + { + this.Options = (StatefulExecutorOptions)base.Options; + this._initialStateFactory = Throw.IfNull(initialStateFactory); + } + + /// + protected new StatefulExecutorOptions Options { get; } + + private string DefaultStateKey => $"{this.GetType().Name}.State"; + + /// + /// Gets the key used to identify the executor's state. + /// + protected string StateKey => this.Options.StateKey ?? this.DefaultStateKey; + + /// + /// Reads the state associated with this executor. If it is not initialized, it will be set to the initial state. + /// + /// The workflow context in which the executor executes. + /// Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable + /// mode. + /// The to monitor for cancellation requests. + /// The default is . + /// + protected async ValueTask ReadStateAsync(IWorkflowContext context, bool skipCache = false, CancellationToken cancellationToken = default) + { + if (!skipCache && this._stateCache is not null) + { + return this._stateCache; + } + + TState? state = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken) + .ConfigureAwait(false); + + if (!context.ConcurrentRunsEnabled) + { + this._stateCache = state; + } + + return state; + } + + /// + /// Queues up an update to the executor's state. + /// + /// The new value of state. + /// The workflow context in which the executor executes. + /// The to monitor for cancellation requests. + /// The default is . + /// + protected ValueTask QueueStateUpdateAsync(TState state, IWorkflowContext context, CancellationToken cancellationToken = default) + { + if (!context.ConcurrentRunsEnabled) + { + this._stateCache = state; + } + + return context.QueueStateUpdateAsync(this.StateKey, state, this.Options.ScopeName, cancellationToken); + } + + /// + /// Invokes an asynchronous operation that reads, updates, and persists workflow state associated with the specified + /// key. + /// + /// A delegate that receives the current state, workflow context, and cancellation token, + /// and returns the updated state asynchronously. + /// The workflow context in which the executor executes. + /// Ignore the cached value, if any. State is not cached when running in Cross-Run Shareable + /// mode. + /// The to monitor for cancellation requests. + /// The default is . + /// A ValueTask that represents the asynchronous operation. + protected async ValueTask InvokeWithStateAsync( + Func> invocation, + IWorkflowContext context, + bool skipCache = false, + CancellationToken cancellationToken = default) + { + if (!skipCache && !context.ConcurrentRunsEnabled) + { + TState newState = await invocation(this._stateCache ?? (this._initialStateFactory()), + context, + cancellationToken).ConfigureAwait(false) + ?? this._initialStateFactory(); + + await context.QueueStateUpdateAsync(this.StateKey, + newState, + this.Options.ScopeName, + cancellationToken).ConfigureAwait(false); + + this._stateCache = newState; + } + else + { + await context.InvokeWithStateAsync(invocation, + this.StateKey, + this._initialStateFactory, + this.Options.ScopeName, + cancellationToken) + .ConfigureAwait(false); + } + } + + /// + protected ValueTask ResetAsync() + { + this._stateCache = this._initialStateFactory(); + + return default; + } +} + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages, +/// and maintain state across invocations. +/// +/// The type of state associated with this Executor. +/// The type of input message. +/// A unique identifier for the executor. +/// A factory to initialize the state value to be used by the executor. +/// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class StatefulExecutor(string id, Func initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false) + : StatefulExecutor(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync); + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); +} + +/// +/// Provides a simple executor implementation that uses a single message handler function to process incoming messages, +/// and maintain state across invocations. +/// +/// The type of state associated with this Executor. +/// The type of input message. +/// The type of output message. +/// A unique identifier for the executor. +/// A factory to initialize the state value to be used by the executor. +/// Configuration options for the executor. If null, default options will be used. +/// Declare that this executor may be used simultaneously by multiple runs safely. +public abstract class StatefulExecutor(string id, Func initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false) + : StatefulExecutor(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler +{ + /// + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => + routeBuilder.AddHandler(this.HandleAsync); + + /// + public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs new file mode 100644 index 00000000000..4ff569374fa --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutorOptions.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// . +/// +public class StatefulExecutorOptions : ExecutorOptions +{ + /// + /// Gets or sets the unique key that identifies the executor's state. If not provided, will default to + /// `{ExecutorType}.State`. + /// + public string? StateKey { get; set; } + + /// + /// Gets or sets the scope name to use for the executor's state. If not provided, the state will be + /// private to this executor instance. + /// + public string? ScopeName { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs index aa70307c1e3..ad6727fc548 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamingRun.cs @@ -31,8 +31,8 @@ internal StreamingRun(AsyncRunHandle runHandle) /// /// Gets the current execution status of the workflow run. /// - public ValueTask GetStatusAsync(CancellationToken cancellation = default) - => this._runHandle.GetStatusAsync(cancellation); + public ValueTask GetStatusAsync(CancellationToken cancellationToken = default) + => this._runHandle.GetStatusAsync(cancellationToken); /// /// Asynchronously sends the specified response to the external system and signals completion of the current @@ -67,7 +67,7 @@ internal ValueTask TrySendMessageUntypedAsync(object message, Type? declar /// progresses. The stream completes when a is encountered. Events are /// delivered in the order they are raised. /// A that can be used to cancel the streaming operation. If cancellation is - /// requested, the stream will end and no further events will be yielded. + /// requested, the stream will end and no further events will be yielded, but this will not cancel the workflow execution. /// An asynchronous stream of objects representing significant workflow state changes. /// The stream ends when the workflow completes or when cancellation is requested. public IAsyncEnumerable WatchStreamAsync( diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs index 49643321486..8205d40b208 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Workflow.cs @@ -66,6 +66,11 @@ public Dictionary ReflectPorts() /// public string? Description { get; internal init; } + internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent); + + internal IEnumerable NonConcurrentExecutorIds => + this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id); + /// /// Initializes a new instance of the class with the specified starting executor identifier /// and input type. @@ -140,6 +145,23 @@ private async ValueTask TryResetExecutorRegistrationsAsync() private object? _ownerToken; private bool _ownedAsSubworkflow; + + internal void CheckOwnership(object? existingOwnershipSignoff = null) + { + object? maybeOwned = Volatile.Read(ref this._ownerToken); + if (!ReferenceEquals(maybeOwned, existingOwnershipSignoff)) + { + throw new InvalidOperationException($"Existing ownership does not match check value. {Summarize(maybeOwned)} vs. {Summarize(existingOwnershipSignoff)}"); + } + + string Summarize(object? maybeOwnerToken) => maybeOwnerToken switch + { + string s => $"'{s}'", + null => "", + _ => $"{maybeOwnerToken.GetType().Name}@{maybeOwnerToken.GetHashCode()}", + }; + } + internal void TakeOwnership(object ownerToken, bool subworkflow = false, object? existingOwnershipSignoff = null) { object? maybeToken = Interlocked.CompareExchange(ref this._ownerToken, ownerToken, existingOwnershipSignoff); @@ -180,19 +202,18 @@ internal void TakeOwnership(object ownerToken, bool subworkflow = false, object? Justification = "Does not exist in NetFx 4.7.2")] internal async ValueTask ReleaseOwnershipAsync(object ownerToken) { - if (this._ownerToken == null) + object? originalToken = Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken); + if (originalToken == null) { throw new InvalidOperationException("Attempting to release ownership of a Workflow that is not owned."); } - if (!ReferenceEquals(this._ownerToken, this._ownerToken)) + if (!ReferenceEquals(originalToken, ownerToken)) { throw new InvalidOperationException("Attempt to release ownership of a Workflow by non-owner."); } await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false); - - Interlocked.CompareExchange(ref this._ownerToken, null, ownerToken); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs index 7a848de4e58..94e4f20594f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Observability; using Microsoft.Shared.Diagnostics; @@ -414,23 +415,13 @@ private Workflow BuildInternal(Activity? activity = null) { activity?.SetTag(Tags.WorkflowDescription, workflow.Description); } - if (activity is not null) - { - var workflowJsonDefinitionData = new WorkflowJsonDefinitionData - { - StartExecutorId = this._startExecutorId, - Edges = this._edges.Values.SelectMany(e => e), - Ports = this._inputPorts.Values, - OutputExecutors = this._outputExecutors - }; - activity.SetTag( + activity?.SetTag( Tags.WorkflowDefinition, JsonSerializer.Serialize( - workflowJsonDefinitionData, - WorkflowJsonDefinitionJsonContext.Default.WorkflowJsonDefinitionData + workflow.ToWorkflowInfo(), + WorkflowsJsonUtilities.JsonContext.Default.WorkflowInfo ) ); - } return workflow; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs index 965a23a22f9..54b36a8c64e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -16,22 +14,29 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowHostAgent : AIAgent { - private readonly Workflow> _workflow; + private readonly Workflow _workflow; private readonly string? _id; + private readonly CheckpointManager? _checkpointManager; + private readonly IWorkflowExecutionEnvironment _executionEnvironment; private readonly ConcurrentDictionary _assignedRunIds = []; - private readonly Dictionary _runningWorkflows = []; - public WorkflowHostAgent(Workflow> workflow, string? id = null, string? name = null) + public WorkflowHostAgent(Workflow> workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null) { this._workflow = Throw.IfNull(workflow); + this._executionEnvironment = executionEnvironment ?? (workflow.AllowConcurrent + ? InProcessExecution.Concurrent + : InProcessExecution.OffThread); + this._checkpointManager = checkpointManager; this._id = id; this.Name = name; + this.Description = description; } - public override string? Name { get; } public override string Id => this._id ?? base.Id; + public override string? Name { get; } + public override string? Description { get; } private string GenerateNewId() { @@ -45,59 +50,10 @@ private string GenerateNewId() return result; } - public override AgentThread GetNewThread() => new WorkflowThread(this.Id, this.Name, this.GenerateNewId()); + public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager); public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) - => new WorkflowThread(serializedThread, jsonSerializerOptions); - - private async - IAsyncEnumerable InvokeStageAsync( - WorkflowThread conversation, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - string runId = conversation.RunId; - List messages = conversation.MessageStore.GetFromBookmark().ToList(); - - try - { - // technically there is a race condition here between assigning the ID, and checking if it exists - // in the case of new threads. - if (!this._runningWorkflows.TryGetValue(runId, out StreamingRun? run)) - { - run = await InProcessExecution.StreamAsync(this._workflow, messages, cancellationToken: cancellationToken) - .ConfigureAwait(false); - this._runningWorkflows[runId] = run; - } - else - { - bool sentMessages = await run.TrySendMessageAsync(messages).ConfigureAwait(false); - Debug.Assert(sentMessages, "Hosted workflow is required to take List as input."); - } - - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) - { - switch (evt) - { - case AgentRunUpdateEvent agentUpdate: - yield return agentUpdate.Update; - break; - case RequestInfoEvent requestInfo: - FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); - AgentRunResponseUpdate update = conversation.CreateUpdate(fcContent); - yield return update; - break; - } - } - } - finally - { - // Do we want to try to undo the step, and not update the bookmark? - conversation.MessageStore.UpdateBookmark(); - } - } + => new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions); private async ValueTask UpdateThreadAsync(IEnumerable messages, AgentThread? thread = null, CancellationToken cancellationToken = default) { @@ -122,14 +78,14 @@ Task RunAsync( WorkflowThread workflowThread = await this.UpdateThreadAsync(messages, thread, cancellationToken).ConfigureAwait(false); MessageMerger merger = new(); - await foreach (AgentRunResponseUpdate update in this.InvokeStageAsync(workflowThread, cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + await foreach (AgentRunResponseUpdate update in workflowThread.InvokeStageAsync(cancellationToken) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { merger.AddUpdate(update); } - return merger.ComputeMerged(workflowThread.ResponseId, this.Id, this.Name); + return merger.ComputeMerged(workflowThread.LastResponseId!, this.Id, this.Name); } public override async @@ -140,9 +96,9 @@ IAsyncEnumerable RunStreamingAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default) { WorkflowThread workflowThread = await this.UpdateThreadAsync(messages, thread, cancellationToken).ConfigureAwait(false); - await foreach (AgentRunResponseUpdate update in this.InvokeStageAsync(workflowThread, cancellationToken) - .ConfigureAwait(false) - .WithCancellation(cancellationToken)) + await foreach (AgentRunResponseUpdate update in workflowThread.InvokeStageAsync(cancellationToken) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) { yield return update; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs index b41034b317d..e3a357a1343 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs @@ -15,23 +15,45 @@ public static class WorkflowHostingExtensions /// /// Convert a workflow with the appropriate primary input type to an . /// - /// - /// - /// + /// The workflow to be hosted by the resulting + /// A unique id for the hosting . + /// A name for the hosting . + /// A description for the hosting . + /// A to enable persistence of run state. + /// Specify the execution environment to use when running the workflows. See + /// , and + /// for the in-process environments. /// - public static AIAgent AsAgent(this Workflow> workflow, string? id = null, string? name = null) + public static AIAgent AsAgent( + this Workflow> workflow, + string? id = null, + string? name = null, + string? description = null, + CheckpointManager? checkpointManager = null, + IWorkflowExecutionEnvironment? executionEnvironment = null) { - return new WorkflowHostAgent(workflow, id, name); + return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment); } /// /// Convert a workflow with the appropriate primary input type to an . /// - /// - /// - /// + /// The workflow to be hosted by the resulting + /// A unique id for the hosting . + /// A name for the hosting . + /// /// A description for the hosting . + /// A to enable persistence of run state. + /// Specify the execution environment to use when running the workflows. See + /// , and + /// for the in-process environments. /// - public static async ValueTask AsAgentAsync(this Workflow workflow, string? id = null, string? name = null) + public static async ValueTask AsAgentAsync( + this Workflow workflow, + string? id = null, + string? name = null, + string? description = null, + CheckpointManager? checkpointManager = null, + IWorkflowExecutionEnvironment? executionEnvironment = null) { Workflow>? maybeTyped = await workflow.TryPromoteAsync>() .ConfigureAwait(false); @@ -41,7 +63,7 @@ public static async ValueTask AsAgentAsync(this Workflow workflow, stri throw new InvalidOperationException("Cannot host a workflow that does not accept List as an input"); } - return maybeTyped.AsAgent(id: id, name: name); + return maybeTyped.AsAgent(id, name, description, checkpointManager, executionEnvironment); } internal static FunctionCallContent ToFunctionCall(this ExternalRequest request) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs deleted file mode 100644 index d4e177b3388..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowJsonDefintion.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Workflows; - -[JsonSourceGenerationOptions(UseStringEnumConverter = true)] -[JsonSerializable(typeof(WorkflowJsonDefinitionData))] -internal partial class WorkflowJsonDefinitionJsonContext : JsonSerializerContext -{ -} - -internal class WorkflowJsonDefinitionData -{ - public string StartExecutorId { get; set; } = string.Empty; - public IEnumerable Edges { get; set; } = []; - public IEnumerable Ports { get; set; } = []; - public IEnumerable OutputExecutors { get; set; } = []; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs index 1fffb502fca..39c83bcadf1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowMessageStore.cs @@ -1,11 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -18,22 +18,22 @@ public WorkflowMessageStore() { } - public WorkflowMessageStore(JsonElement serializedStoreState, JsonSerializerOptions? jsonSerializerOptions = null) + public WorkflowMessageStore(StoreState state) { - if (serializedStoreState.ValueKind is not JsonValueKind.Object) + this.ImportStoreState(Throw.IfNull(state)); + } + + private void ImportStoreState(StoreState state, bool clearMessages = false) + { + if (clearMessages) { - throw new ArgumentException("The provided JsonElement must be a json object", nameof(serializedStoreState)); + this._chatMessages.Clear(); } - StoreState? state = - serializedStoreState.Deserialize( - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))) as StoreState; - if (state?.Messages is not null) { this._chatMessages.AddRange(state.Messages); } - this._bookmark = state?.Bookmark ?? 0; } @@ -66,13 +66,11 @@ public IEnumerable GetFromBookmark() public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) { - StoreState state = new() - { - Bookmark = this._bookmark, - Messages = this._chatMessages, - }; + StoreState state = this.ExportStoreState(); return JsonSerializer.SerializeToElement(state, WorkflowsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StoreState))); } + + internal StoreState ExportStoreState() => new() { Bookmark = this._bookmark, Messages = this._chatMessages }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs index 64c7b5be4ca..760f2ae029c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowOutputEvent.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI.Workflows; @@ -26,6 +27,24 @@ internal WorkflowOutputEvent(object data, string sourceId) : base(data) /// true if the underlying data is assignable to type T; otherwise, false. public bool Is() => this.IsType(typeof(T)); + /// + /// Determines whether the underlying data is of the specified type or a derived type, and + /// returns it as that type if it is. + /// + /// The type to compare with the type of the underlying data. + /// true if the underlying data is assignable to type T; otherwise, false. + public bool Is([NotNullWhen(true)] out T? maybeValue) + { + if (this.Data is T value) + { + maybeValue = value; + return true; + } + + maybeValue = default; + return false; + } + /// /// Determines whether the underlying data is of the specified type or a derived type. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs index 8db2eabe93d..160785a49a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowThread.cs @@ -1,7 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; @@ -9,26 +15,75 @@ namespace Microsoft.Agents.AI.Workflows; internal sealed class WorkflowThread : AgentThread { - public WorkflowThread(string workflowId, string? workflowName, string runId) + private readonly Workflow _workflow; + private readonly IWorkflowExecutionEnvironment _executionEnvironment; + + private readonly CheckpointManager _checkpointManager; + private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager; + + public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null) { - this.MessageStore = new(); + this._workflow = Throw.IfNull(workflow); + this._executionEnvironment = Throw.IfNull(executionEnvironment); + + // If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one. + // TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded + // memory growth. + this._checkpointManager = checkpointManager ?? new(this._inMemoryCheckpointManager = new()); + this.RunId = Throw.IfNullOrEmpty(runId); + this.MessageStore = new WorkflowMessageStore(); } - public WorkflowThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) + public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null) { - throw new NotImplementedException("Pending Checkpointing work."); + this._workflow = Throw.IfNull(workflow); + this._executionEnvironment = Throw.IfNull(executionEnvironment); + + JsonMarshaller marshaller = new(jsonSerializerOptions); + ThreadState threadState = marshaller.Marshal(serializedThread); + + this._inMemoryCheckpointManager = threadState.CheckpointManager; + if (this._inMemoryCheckpointManager is not null && checkpointManager is not null) + { + // The thread was externalized with an in-memory checkpoint manager, but the caller is providing an external one. + throw new ArgumentException("Cannot provide an external checkpoint manager when deserializing a thread that " + + "was serialized with an in-memory checkpoint manager.", nameof(checkpointManager)); + } + else if (this._inMemoryCheckpointManager is null && checkpointManager is null) + { + // The thread was externalized without an in-memory checkpoint manager, and the caller is not providing an external one. + throw new ArgumentException("An external checkpoint manager must be provided when deserializing a thread that " + + "was serialized without an in-memory checkpoint manager.", nameof(checkpointManager)); + } + else + { + this._checkpointManager = checkpointManager ?? new(this._inMemoryCheckpointManager!); + } + + this.RunId = threadState.RunId; + this.LastCheckpoint = threadState.LastCheckpoint; + this.MessageStore = new WorkflowMessageStore(threadState.MessageStoreState); } - public string RunId { get; } - public int Halts { get; } + public CheckpointInfo? LastCheckpoint { get; set; } - public string ResponseId => $"{this.RunId}@{this.Halts}"; + protected override Task MessagesReceivedAsync(IEnumerable newMessages, CancellationToken cancellationToken = default) + => this.MessageStore.AddMessagesAsync(newMessages, cancellationToken); public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) - => throw new NotImplementedException("Pending Checkpointing work."); + { + JsonMarshaller marshaller = new(jsonSerializerOptions); + ThreadState info = new( + this.RunId, + this.LastCheckpoint, + this.MessageStore.ExportStoreState(), + this._inMemoryCheckpointManager); + + return marshaller.Marshal(info); + } - public AgentRunResponseUpdate CreateUpdate(params AIContent[] parts) + public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] parts) { Throw.IfNullOrEmpty(parts); @@ -36,6 +91,8 @@ public AgentRunResponseUpdate CreateUpdate(params AIContent[] parts) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Assistant, + ResponseId = responseId }; this.MessageStore.AddMessages(update.ToChatMessage()); @@ -43,6 +100,91 @@ public AgentRunResponseUpdate CreateUpdate(params AIContent[] parts) return update; } + private async ValueTask> CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default) + { + if (this.LastCheckpoint is not null) + { + Checkpointed checkpointed = + await this._executionEnvironment + .ResumeStreamAsync(this._workflow, + this.LastCheckpoint, + this._checkpointManager, + this.RunId, + cancellationToken) + .ConfigureAwait(false); + + await checkpointed.Run.TrySendMessageAsync(messages).ConfigureAwait(false); + return checkpointed; + } + + return await this._executionEnvironment + .StreamAsync(this._workflow, + messages, + this._checkpointManager, + this.RunId, + cancellationToken) + .ConfigureAwait(false); + } + + internal async + IAsyncEnumerable InvokeStageAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + try + { + this.LastResponseId = Guid.NewGuid().ToString("N"); + List messages = this.MessageStore.GetFromBookmark().ToList(); + +#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below. + await using Checkpointed checkpointed = + await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false); +#pragma warning restore CA2007 + + StreamingRun run = checkpointed.Run; + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken) + .ConfigureAwait(false) + .WithCancellation(cancellationToken)) + { + switch (evt) + { + case AgentRunUpdateEvent agentUpdate: + yield return agentUpdate.Update; + break; + case RequestInfoEvent requestInfo: + FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall(); + AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent); + yield return update; + break; + case SuperStepCompletedEvent stepCompleted: + this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; + } + } + } + finally + { + // Do we want to try to undo the step, and not update the bookmark? + this.MessageStore.UpdateBookmark(); + } + } + + public string? LastResponseId { get; set; } + + public string RunId { get; } + /// public WorkflowMessageStore MessageStore { get; } + + internal sealed class ThreadState( + string runId, + CheckpointInfo? lastCheckpoint, + WorkflowMessageStore.StoreState messageStoreState, + InMemoryCheckpointManager? checkpointManager = null) + { + public string RunId { get; } = runId; + public CheckpointInfo? LastCheckpoint { get; } = lastCheckpoint; + public WorkflowMessageStore.StoreState MessageStoreState { get; } = messageStoreState; + public InMemoryCheckpointManager? CheckpointManager { get; } = checkpointManager; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs index 33cf002517f..752bb4bac76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowsJsonUtilities.cs @@ -6,8 +6,8 @@ using System.Text.Json.Serialization; using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; -using static Microsoft.Agents.AI.Workflows.WorkflowMessageStore; namespace Microsoft.Agents.AI.Workflows; @@ -80,7 +80,8 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(EdgeConnection))] // Workflow-as-Agent - [JsonSerializable(typeof(StoreState))] + [JsonSerializable(typeof(WorkflowMessageStore.StoreState))] + [JsonSerializable(typeof(WorkflowThread.ThreadState))] // Message Types [JsonSerializable(typeof(ChatMessage))] @@ -88,10 +89,13 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(ExternalResponse))] [JsonSerializable(typeof(TurnToken))] + // Built-in Executor State Types + [JsonSerializable(typeof(AIAgentHostExecutor))] + // Event Types //[JsonSerializable(typeof(WorkflowEvent))] // Currently cannot be serialized because it includes Exceptions. - // We'll need a way to marshal this correct in the AgentRuntime case. + // We'll need a way to marshal this correctly in the AgentRuntime case. // For now this is okay, because we never serialize WorkflowEvents into // checkpoints. [JsonSerializable(typeof(JsonElement))] diff --git a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs index e58fbf4920a..097b789a844 100644 --- a/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/AgentExtensions.cs @@ -2,6 +2,7 @@ using System; using System.ComponentModel; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -12,7 +13,7 @@ namespace Microsoft.Agents.AI; /// /// Provides extensions for . /// -public static class AIAgentExtensions +public static partial class AIAgentExtensions { /// /// Creates a new using the specified agent as the foundation for the builder pipeline. @@ -77,9 +78,32 @@ async Task InvokeAgentAsync( } options ??= new(); - options.Name ??= agent.Name; + options.Name ??= SanitizeAgentName(agent.Name); options.Description ??= agent.Description; return AIFunctionFactory.Create(InvokeAgentAsync, options); } + + /// + /// Removes characters from AI agent name that shouldn't be used in an AI function name. + /// + /// The AI agent name to sanitize. + /// + /// The sanitized agent name with invalid characters replaced by underscores, or null if the input is null. + /// + private static string? SanitizeAgentName(string? agentName) + { + return agentName is null + ? agentName + : InvalidNameCharsRegex().Replace(agentName, "_"); + } + + /// Regex that flags any character other than ASCII digits or letters. +#if NET + [GeneratedRegex("[^0-9A-Za-z]+")] + private static partial Regex InvalidNameCharsRegex(); +#else + private static Regex InvalidNameCharsRegex() => s_invalidNameCharsRegex; + private static readonly Regex s_invalidNameCharsRegex = new("[^0-9A-Za-z]+", RegexOptions.Compiled); +#endif } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs index b51b86c64e2..f83e6912d5c 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentOptions.cs @@ -44,11 +44,6 @@ public ChatClientAgentOptions(string? instructions, string? name = null, string? { (this.ChatOptions ??= new()).Tools = tools; } - - if (instructions is not null) - { - (this.ChatOptions ??= new()).Instructions = instructions; - } } /// @@ -106,7 +101,7 @@ public ChatClientAgentOptions(string? instructions, string? name = null, string? /// /// Creates a new instance of with the same values as this instance. /// - internal ChatClientAgentOptions Clone() + public ChatClientAgentOptions Clone() => new() { Id = this.Id, diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs index 4c93fe4295d..baa36c00548 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentThread.cs @@ -52,7 +52,7 @@ internal ChatClientAgentThread( } var state = serializedThreadState.Deserialize( - AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; + AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))) as ThreadState; this.AIContextProvider = aiContextProviderFactory?.Invoke(state?.AIContextProviderState ?? default, jsonSerializerOptions); @@ -170,7 +170,7 @@ public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptio AIContextProviderState = aiContextProviderState }; - return JsonSerializer.SerializeToElement(state, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))); + return JsonSerializer.SerializeToElement(state, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ThreadState))); } /// diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs index 124f66760e6..fd4b6df60ab 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientBuilderExtensions.cs @@ -2,11 +2,11 @@ using System; using System.Collections.Generic; -using Microsoft.Extensions.AI; +using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.ChatClient; +namespace Microsoft.Extensions.AI; /// /// Provides extension methods for building a from a . diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 247bd12cbca..e7b3f42bb94 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -139,33 +139,12 @@ private void UpdateCurrentActivity(Activity? previousActivity) /// State passed from this instance into the inner agent, circumventing the intermediate . private sealed class ForwardedOptions : ChatOptions { - public ForwardedOptions(AgentRunOptions? options, AgentThread? thread, Activity? currentActivity) + public ForwardedOptions(AgentRunOptions? options, AgentThread? thread, Activity? currentActivity) : + base((options as ChatClientAgentRunOptions)?.ChatOptions) { this.Options = options; this.Thread = thread; this.CurrentActivity = currentActivity; - - if (options is ChatClientAgentRunOptions { ChatOptions: { } chatClientOptions }) - { - // Keep this faux copy ctor in sync with public properties on ChatOptions. - this.AdditionalProperties = chatClientOptions.AdditionalProperties; - this.AllowMultipleToolCalls = chatClientOptions.AllowMultipleToolCalls; - this.ConversationId = chatClientOptions.ConversationId; - this.FrequencyPenalty = chatClientOptions.FrequencyPenalty; - this.Instructions = chatClientOptions.Instructions; - this.MaxOutputTokens = chatClientOptions.MaxOutputTokens; - this.ModelId = chatClientOptions.ModelId; - this.PresencePenalty = chatClientOptions.PresencePenalty; - this.RawRepresentationFactory = chatClientOptions.RawRepresentationFactory; - this.ResponseFormat = chatClientOptions.ResponseFormat; - this.Seed = chatClientOptions.Seed; - this.StopSequences = chatClientOptions.StopSequences; - this.Temperature = chatClientOptions.Temperature; - this.Tools = chatClientOptions.Tools; - this.ToolMode = chatClientOptions.ToolMode; - this.TopK = chatClientOptions.TopK; - this.TopP = chatClientOptions.TopP; - } } public AgentRunOptions? Options { get; } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj index c53ca9de0fd..f5cafd29758 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj @@ -7,6 +7,7 @@ + diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs new file mode 100644 index 00000000000..32b51b41965 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Shared.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentCreateTests +{ + private static readonly AzureAIConfiguration s_config = TestConfiguration.LoadSection(); + private readonly PersistentAgentsClient _persistentAgentsClient = new(s_config.Endpoint, new AzureCliCredential()); + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism) + { + // Arrange. + const string AgentName = "IntegrationTestAgent"; + const string AgentDescription = "An agent created during integration tests"; + const string AgentInstructions = "You are an integration test agent"; + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription)), + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + name: AgentName, + description: AgentDescription), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + Assert.NotNull(agent); + Assert.Equal(AgentName, agent.Name); + Assert.Equal(AgentDescription, agent.Description); + Assert.Equal(AgentInstructions, agent.Instructions); + + var retrievedAgentMetadata = await this._persistentAgentsClient.Administration.GetAgentAsync(agent.Id); + Assert.NotNull(retrievedAgentMetadata); + Assert.Equal(AgentName, retrievedAgentMetadata.Value.Name); + Assert.Equal(AgentDescription, retrievedAgentMetadata.Value.Description); + Assert.Equal(AgentInstructions, retrievedAgentMetadata.Value.Instructions); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Create a vector store. + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText( + path: searchFilePath, + contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457." + ); + PersistentAgentFileInfo uploadedAgentFile = this._persistentAgentsClient.Files.UploadFile( + filePath: searchFilePath, + purpose: PersistentAgentFilePurpose.Agents + ); + var vectorStoreMetadata = await this._persistentAgentsClient.VectorStores.CreateVectorStoreAsync([uploadedAgentFile.Id], name: "WordCodeLookup_VectorStore"); + + // Act. + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }])), + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new FileSearchToolDefinition()], + toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new FileSearchToolDefinition()], + toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + // Verify that the agent can use the vector store to answer a question. + var result = await agent.RunAsync("Can you give me the documented code for 'banana'?"); + Assert.Contains("673457", result.ToString()); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + await this._persistentAgentsClient.VectorStores.DeleteVectorStoreAsync(vectorStoreMetadata.Value.Id); + await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedAgentFile.Id); + File.Delete(searchFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithFoundryOptionsAsync")] + [InlineData("CreateWithFoundryOptionsSync")] + public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = """ + You are a helpful coding agent. A Python file is provided. Use the Code Interpreter Tool to run the file + and report the SECRET_NUMBER value it prints. Respond only with the number. + """; + + // Create a python file that prints a known value. + var codeFilePath = Path.GetTempFileName() + "secret_number.py"; + File.WriteAllText( + path: codeFilePath, + contents: "print(\"SECRET_NUMBER=24601\")" // Deterministic output we will look for. + ); + PersistentAgentFileInfo uploadedCodeFile = this._persistentAgentsClient.Files.UploadFile( + filePath: codeFilePath, + purpose: PersistentAgentFilePurpose.Agents + ); + CodeInterpreterToolResource toolResource = new(); + toolResource.FileIds.Add(uploadedCodeFile.Id); + + // Act. + var agent = createMechanism switch + { + // Hosted tool path (tools supplied via ChatClientAgentOptions) + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }])), + // Foundry (definitions + resources provided directly) + "CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new CodeInterpreterToolDefinition()], + toolResources: new ToolResources() { CodeInterpreter = toolResource }), + "CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + instructions: AgentInstructions, + tools: [new CodeInterpreterToolDefinition()], + toolResources: new ToolResources() { CodeInterpreter = toolResource }), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Assert. + var result = await agent.RunAsync("What is the SECRET_NUMBER?"); + // We expect the model to run the code and surface the number. + Assert.Contains("24601", result.ToString()); + } + finally + { + // Cleanup. + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + await this._persistentAgentsClient.Files.DeleteFileAsync(uploadedCodeFile.Id); + File.Delete(codeFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism) + { + // Arrange. + const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; + + static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; + var weatherFunction = AIFunctionFactory.Create(GetWeather); + + ChatClientAgent agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent( + s_config.DeploymentName, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Act. + var response = await agent.RunAsync("What is the weather like in Amsterdam?"); + + // Assert - ensure function was invoked and its output surfaced. + var text = response.Text; + Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs new file mode 100644 index 00000000000..48d1c699e98 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentCardExtensionsTests +{ + private readonly AgentCard _agentCard; + + public A2AAgentCardExtensionsTests() + { + this._agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for unit testing", + Url = "http://test-endpoint/agent" + }; + } + + [Fact] + public async Task GetAIAgentAsync_ReturnsAIAgentAsync() + { + // Act + var agent = await this._agentCard.GetAIAgentAsync(); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("A test agent for unit testing", agent.Description); + } + + [Fact] + public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() + { + // Arrange + using var handler = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(handler, false); + + handler.ResponsesToReturn.Enqueue(new AgentMessage + { + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response" }], + }); + + var agent = await this._agentCard.GetAIAgentAsync(httpClient); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Single(handler.CapturedUris); + Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]); + } + + internal sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public Queue ResponsesToReturn { get; } = new(); + + public List CapturedUris { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.CapturedUris.Add(request.RequestUri!); + + var response = this.ResponsesToReturn.Dequeue(); + + if (response is AgentCard agentCard) + { + var json = JsonSerializer.Serialize(agentCard); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + else if (response is AgentMessage message) + { + var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + + // Return empty agent card if none specified + var emptyCard = new AgentCard(); + var emptyJson = JsonSerializer.Serialize(emptyCard); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyJson, Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index a956fc8986b..f654f3eeecb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs index cc3247416a5..a653cf80f5c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunResponseUpdateExtensionsTests.cs @@ -207,7 +207,7 @@ public async Task ToAgentRunResponseUsesContentExtractedFromContentsAsync() Assert.Equal("Hello, world!", Assert.IsType(Assert.Single(Assert.Single(response.Messages).Contents)).Text); } - [Theory(Skip = "Reactive once M.E.AI 9.10 is imported")] + [Theory] [InlineData(false)] [InlineData(true)] public async Task ToAgentRunResponse_AlternativeTimestampsAsync(bool useAsync) @@ -275,7 +275,7 @@ public async Task ToAgentRunResponse_AlternativeTimestampsAsync(bool useAsync) } } - [Theory(Skip = "Reactive once M.E.AI 9.10 is imported")] + [Theory] [MemberData(nameof(ToAgentRunResponse_TimestampFolding_MemberData))] public async Task ToAgentRunResponse_TimestampFoldingAsync(bool useAsync, string? timestamp1, string? timestamp2, string? expectedTimestamp) { diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs index 705136ba0e4..2405cd3347c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -294,6 +294,438 @@ public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync() Assert.Null(retrievedTestClient); } + /// + /// Verify that GetAIAgent with Response and options works correctly. + /// + [Fact] + public void GetAIAgent_WithResponseAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + var response = Response.FromValue(persistentAgent, new FakeResponse()); + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(response, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with PersistentAgent and options works correctly. + /// + [Fact] + public void GetAIAgent_WithPersistentAgentAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(persistentAgent, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with PersistentAgent and options falls back to agent metadata when options are null. + /// + [Fact] + public void GetAIAgent_WithPersistentAgentAndOptionsWithNullFields_FallsBackToAgentMetadata() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions(); // Empty options + + // Act + var agent = client.GetAIAgent(persistentAgent, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Original Name", agent.Name); + Assert.Equal("Original Description", agent.Description); + Assert.Equal("Original Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with agentId and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string AgentId = "agent_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = client.GetAIAgent(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgentAsync with agentId and options works correctly. + /// + [Fact] + public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string AgentId = "agent_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = await client.GetAIAgentAsync(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void GetAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123", "name": "Test Agent"}"""))!; + var testChatClient = new TestChatClient(client.AsIChatClient("agent_abc123")); + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = client.GetAIAgent( + persistentAgent, + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when response is null. + /// + [Fact] + public void GetAIAgent_WithNullResponse_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent((Response)null!, options)); + + Assert.Equal("persistentAgentResponse", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when persistentAgent is null. + /// + [Fact] + public void GetAIAgent_WithNullPersistentAgent_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent((PersistentAgent)null!, options)); + + Assert.Equal("persistentAgentMetadata", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void GetAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var persistentAgent = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "agent_abc123"}"""))!; + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(persistentAgent, (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when agentId is empty. + /// + [Fact] + public void GetAIAgent_WithOptionsAndEmptyAgentId_ThrowsArgumentException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.GetAIAgent(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty. + /// + [Fact] + public async Task GetAIAgentAsync_WithOptionsAndEmptyAgentId_ThrowsArgumentExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.GetAIAgentAsync(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent with options works correctly. + /// + [Fact] + public void CreateAIAgent_WithOptions_WorksCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = client.CreateAIAgent(Model, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgentAsync with options works correctly. + /// + [Fact] + public async Task CreateAIAgentAsync_WithOptions_WorksCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent", + Description = "Test description", + Instructions = "Test instructions" + }; + + // Act + var agent = await client.CreateAIAgentAsync(Model, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("Test description", agent.Description); + Assert.Equal("Test instructions", agent.Instructions); + } + + /// + /// Verify that CreateAIAgent with options and clientFactory applies the factory correctly. + /// + [Fact] + public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = client.CreateAIAgent( + Model, + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgentAsync with options and clientFactory applies the factory correctly. + /// + [Fact] + public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + TestChatClient? testChatClient = null; + const string Model = "test-model"; + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = await client.CreateAIAgentAsync( + Model, + options, + clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient)); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that CreateAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent("test-model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentNullException when options is null. + /// + [Fact] + public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that CreateAIAgent throws ArgumentException when model is empty. + /// + [Fact] + public void CreateAIAgent_WithEmptyModel_ThrowsArgumentException() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + client.CreateAIAgent(string.Empty, options)); + + Assert.Equal("model", exception.ParamName); + } + + /// + /// Verify that CreateAIAgentAsync throws ArgumentException when model is empty. + /// + [Fact] + public async Task CreateAIAgentAsync_WithEmptyModel_ThrowsArgumentExceptionAsync() + { + // Arrange + var client = CreateFakePersistentAgentsClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + client.CreateAIAgentAsync(string.Empty, options)); + + Assert.Equal("model", exception.ParamName); + } + /// /// Test custom chat client that can be used to verify clientFactory functionality. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj index 9b061c0c461..9e0a79d6463 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.Tests/Microsoft.Agents.AI.Hosting.A2A.Tests.csproj @@ -2,11 +2,12 @@ $(ProjectsCoreTargetFrameworks) + $(ProjectsDebugCoreTargetFrameworks) - - + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs index c9a70cc1899..a29a6208f98 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderAgentExtensionsTests.cs @@ -19,25 +19,6 @@ public void AddAIAgent_NullBuilder_ThrowsArgumentNullException() => Assert.Throws( () => HostApplicationBuilderAgentExtensions.AddAIAgent(null!, "agent", "instructions")); - /// - /// Verifies that AddAIAgent with valid parameters returns the same builder instance. - /// - /// The chat client key to use, or null to use the default service. - [Theory] - [InlineData(null)] - [InlineData("customKey")] - public void AddAIAgent_ValidParameters_ReturnsBuilder(string? chatClientKey) - { - // Arrange - var builder = new HostApplicationBuilder(); - - // Act - var result = builder.AddAIAgent("agentName", "instructions", chatClientKey); - - // Assert - Assert.Same(builder, result); - } - /// /// Verifies that AddAIAgent without chat client key throws ArgumentNullException for null name. /// @@ -59,14 +40,9 @@ public void AddAIAgent_NullName_ThrowsArgumentNullException() [Fact] public void AddAIAgent_NullInstructions_AllowsNull() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", (string)null!); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -90,14 +66,9 @@ public void AddAIAgentWithKey_NullName_ThrowsArgumentNullException() [Fact] public void AddAIAgentWithKey_NullInstructions_AllowsNull() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", null!, "key"); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -148,15 +119,11 @@ public void AddAIAgentWithFactory_NullFactory_ThrowsArgumentNullException() [Fact] public void AddAIAgentWithFactory_ValidParameters_ReturnsBuilder() { - // Arrange var builder = new HostApplicationBuilder(); var mockAgent = new Mock(); - - // Act var result = builder.AddAIAgent("agentName", (sp, key) => mockAgent.Object); - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// @@ -192,9 +159,9 @@ public void AddAIAgent_MultipleCalls_RegistersMultipleAgents() var builder = new HostApplicationBuilder(); // Act - builder.AddAIAgent("agent1", "instructions1") - .AddAIAgent("agent2", "instructions2") - .AddAIAgent("agent3", "instructions3"); + builder.AddAIAgent("agent1", "instructions1"); + builder.AddAIAgent("agent2", "instructions2"); + builder.AddAIAgent("agent3", "instructions3"); // Assert var agentDescriptors = builder.Services @@ -227,14 +194,9 @@ public void AddAIAgent_EmptyName_ThrowsArgumentException() [Fact] public void AddAIAgent_EmptyInstructions_Succeeds() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", ""); - - // Assert - Assert.Same(builder, result); + Assert.NotNull(result); } /// /// Verifies that AddAIAgent without chat client key calls the overload with null key. @@ -242,14 +204,9 @@ public void AddAIAgent_EmptyInstructions_Succeeds() [Fact] public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey() { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent("agentName", "instructions"); - // Assert - Assert.Same(builder, result); // The agent should be registered (proving the method chain worked) var descriptor = builder.Services.FirstOrDefault( d => d.ServiceKey is "agentName" && @@ -270,14 +227,9 @@ public void AddAIAgent_WithoutKey_CallsOverloadWithNullKey() [InlineData("my.agent_1:type-name")] // complex valid name public void AddAIAgent_ValidSpecialCharactersInName_Succeeds(string name) { - // Arrange var builder = new HostApplicationBuilder(); - - // Act var result = builder.AddAIAgent(name, "instructions"); - // Assert - Assert.Same(builder, result); var descriptor = builder.Services.FirstOrDefault( d => (d.ServiceKey as string) == name && d.ServiceType == typeof(AIAgent)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs new file mode 100644 index 00000000000..6c4250943e7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostApplicationBuilderWorkflowExtensionsTests.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.UnitTests; + +public class HostApplicationBuilderWorkflowExtensionsTests +{ + /// + /// Verifies that providing a null builder to AddWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddWorkflow_NullBuilder_ThrowsArgumentNullException() => + Assert.Throws( + () => HostApplicationBuilderWorkflowExtensions.AddWorkflow( + null!, + "workflow", + (sp, key) => CreateTestWorkflow(key))); + + /// + /// Verifies that AddWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddWorkflow(null!, (sp, key) => CreateTestWorkflow(key))); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddWorkflow throws ArgumentNullException for null factory delegate. + /// + [Fact] + public void AddWorkflow_NullFactory_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddWorkflow("workflowName", null!)); + Assert.Equal("createWorkflowDelegate", exception.ParamName); + } + + /// + /// Verifies that AddWorkflow returns the IHostWorkflowBuilder instance. + /// + [Fact] + public void AddWorkflow_ValidParameters_ReturnsBuilder() + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddWorkflow("workflowName", (sp, key) => CreateTestWorkflow(key)); + + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + + /// + /// Verifies that AddWorkflow registers the workflow as a keyed singleton service. + /// + [Fact] + public void AddWorkflow_RegistersKeyedSingleton() + { + var builder = new HostApplicationBuilder(); + const string WorkflowName = "testWorkflow"; + + builder.AddWorkflow(WorkflowName, (sp, key) => CreateTestWorkflow(key)); + + var descriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == WorkflowName && + d.ServiceType == typeof(Workflow)); + + Assert.NotNull(descriptor); + Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); + } + + /// + /// Verifies that AddWorkflow can be called multiple times with different workflow names. + /// + [Fact] + public void AddWorkflow_MultipleCalls_RegistersMultipleWorkflows() + { + var builder = new HostApplicationBuilder(); + + builder.AddWorkflow("workflow1", (sp, key) => CreateTestWorkflow(key)); + builder.AddWorkflow("workflow2", (sp, key) => CreateTestWorkflow(key)); + builder.AddWorkflow("workflow3", (sp, key) => CreateTestWorkflow(key)); + + var workflowDescriptors = builder.Services + .Where(d => d.ServiceType == typeof(Workflow) && d.ServiceKey is string) + .ToList(); + + Assert.Equal(3, workflowDescriptors.Count); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow1"); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow2"); + Assert.Contains(workflowDescriptors, d => (string)d.ServiceKey! == "workflow3"); + } + + /// + /// Verifies that AddWorkflow handles empty strings for name. + /// + [Fact] + public void AddWorkflow_EmptyName_ThrowsArgumentException() + { + var builder = new HostApplicationBuilder(); + var result = builder.AddWorkflow("", (sp, key) => CreateTestWorkflow(key)); + Assert.NotNull(result); + } + + /// + /// Verifies that AddWorkflow with special characters in name works correctly for valid names. + /// + [Theory] + [InlineData("workflow_name")] // underscore is allowed + [InlineData("Workflow123")] // alphanumeric is allowed + [InlineData("_workflow")] // can start with underscore + [InlineData("workflow-name")] // dash is allowed + [InlineData("workflow.name")] // period is allowed + [InlineData("workflow:type")] // colon is allowed + [InlineData("my.workflow_1:type-name")] // complex valid name + public void AddWorkflow_ValidSpecialCharactersInName_Succeeds(string name) + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddWorkflow(name, (sp, key) => CreateTestWorkflow(key)); + + var descriptor = builder.Services.FirstOrDefault( + d => (d.ServiceKey as string) == name && + d.ServiceType == typeof(Workflow)); + Assert.NotNull(descriptor); + } + + /// + /// Verifies that providing a null builder to AddConcurrentWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddConcurrentWorkflow_NullBuilder_ThrowsArgumentNullException() + { + Assert.Throws(() => + HostApplicationBuilderWorkflowExtensions.AddConcurrentWorkflow(null!, "workflow", [null!])); + } + + /// + /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddConcurrentWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddConcurrentWorkflow(null!, [new HostedAgentBuilder("test", builder)])); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddConcurrentWorkflow throws ArgumentNullException for null agent builders. + /// + [Fact] + public void AddConcurrentWorkflow_NullAgentBuilders_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddConcurrentWorkflow("workflowName", null!)); + Assert.Equal("agentBuilders", exception.ParamName); + } + + /// + /// Verifies that AddConcurrentWorkflow returns IHostWorkflowBuilder instance. + /// + [Fact] + public void AddConcurrentWorkflow_ValidParameters_ReturnsBuilder() + { + var builder = new HostApplicationBuilder(); + + var result = builder.AddConcurrentWorkflow("concurrentWorkflow", [new HostedAgentBuilder("test", builder)]); + + Assert.NotNull(result); + Assert.IsAssignableFrom(result); + } + + /// + /// Verifies that providing a null builder to AddSequentialWorkflow throws an ArgumentNullException. + /// + [Fact] + public void AddSequentialWorkflow_NullBuilder_ThrowsArgumentNullException() + { + Assert.Throws(() => + HostApplicationBuilderWorkflowExtensions.AddSequentialWorkflow(null!, "workflow", [null!])); + } + + /// + /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null name. + /// + [Fact] + public void AddSequentialWorkflow_NullName_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow(null!, [new HostedAgentBuilder("test", builder)])); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddSequentialWorkflow throws ArgumentNullException for null agent builders. + /// + [Fact] + public void AddSequentialWorkflow_NullAgentBuilders_ThrowsArgumentNullException() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow("workflowName", null!)); + Assert.Equal("agentBuilders", exception.ParamName); + } + + [Fact] + public void AddSequentialWorkflow_EmptyAgentBuilders_Throws() + { + var builder = new HostApplicationBuilder(); + + var exception = Assert.Throws(() => + builder.AddSequentialWorkflow("sequentialWorkflow", Array.Empty())); + Assert.Equal("agentBuilders", exception.ParamName); + } + + /// + /// Helper method to create a simple test workflow with a given name. + /// + private static Workflow CreateTestWorkflow(string name) + { + // Create a simple workflow using AgentWorkflowBuilder + var mockAgent = new Mock(); + mockAgent.Setup(a => a.Name).Returns("testAgent"); + + return AgentWorkflowBuilder.BuildSequential(workflowName: name, agents: [mockAgent.Object]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs index 743cadabc42..61e3f5ef57d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.OpenAI.UnitTests/Extensions/OpenAIAssistantClientExtensionsTests.cs @@ -207,6 +207,254 @@ public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException() Assert.Equal("options", exception.ParamName); } + /// + /// Verify that GetAIAgent with ClientResult and options works correctly. + /// + [Fact] + public void GetAIAgent_WithClientResultAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + var clientResult = ClientResult.FromValue(assistant, new FakePipelineResponse()); + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(clientResult, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with Assistant and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAssistantAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(assistant, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with Assistant and options falls back to assistant metadata when options are null. + /// + [Fact] + public void GetAIAgent_WithAssistantAndOptionsWithNullFields_FallsBackToAssistantMetadata() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}"""))!; + + var options = new ChatClientAgentOptions(); // Empty options + + // Act + var agent = assistantClient.GetAIAgent(assistant, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Original Name", agent.Name); + Assert.Equal("Original Description", agent.Description); + Assert.Equal("Original Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with agentId and options works correctly. + /// + [Fact] + public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string AgentId = "asst_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = assistantClient.GetAIAgent(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgentAsync with agentId and options works correctly. + /// + [Fact] + public async Task GetAIAgentAsync_WithAgentIdAndOptions_WorksCorrectlyAsync() + { + // Arrange + var assistantClient = new TestAssistantClient(); + const string AgentId = "asst_abc123"; + + var options = new ChatClientAgentOptions + { + Name = "Override Name", + Description = "Override Description", + Instructions = "Override Instructions" + }; + + // Act + var agent = await assistantClient.GetAIAgentAsync(AgentId, options); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Override Name", agent.Name); + Assert.Equal("Override Description", agent.Description); + Assert.Equal("Override Instructions", agent.Instructions); + } + + /// + /// Verify that GetAIAgent with clientFactory parameter correctly applies the factory. + /// + [Fact] + public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Test Agent"}"""))!; + var testChatClient = new TestChatClient(assistantClient.AsIChatClient("asst_abc123")); + + var options = new ChatClientAgentOptions + { + Name = "Test Agent" + }; + + // Act + var agent = assistantClient.GetAIAgent( + assistant, + options, + clientFactory: (innerClient) => testChatClient); + + // Assert + Assert.NotNull(agent); + Assert.Equal("Test Agent", agent.Name); + + // Verify that the custom chat client can be retrieved from the agent's service collection + var retrievedTestClient = agent.GetService(); + Assert.NotNull(retrievedTestClient); + Assert.Same(testChatClient, retrievedTestClient); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when assistantClientResult is null. + /// + [Fact] + public void GetAIAgent_WithNullClientResult_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent((ClientResult)null!, options)); + + Assert.Equal("assistantClientResult", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when assistant is null. + /// + [Fact] + public void GetAIAgent_WithNullAssistant_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent((Assistant)null!, options)); + + Assert.Equal("assistantMetadata", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentNullException when options is null. + /// + [Fact] + public void GetAIAgent_WithNullOptions_ThrowsArgumentNullException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var assistant = ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}"""))!; + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent(assistant, (ChatClientAgentOptions)null!)); + + Assert.Equal("options", exception.ParamName); + } + + /// + /// Verify that GetAIAgent throws ArgumentException when agentId is empty. + /// + [Fact] + public void GetAIAgent_WithEmptyAgentId_ThrowsArgumentException() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = Assert.Throws(() => + assistantClient.GetAIAgent(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + + /// + /// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty. + /// + [Fact] + public async Task GetAIAgentAsync_WithEmptyAgentId_ThrowsArgumentExceptionAsync() + { + // Arrange + var assistantClient = new TestAssistantClient(); + var options = new ChatClientAgentOptions(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + assistantClient.GetAIAgentAsync(string.Empty, options)); + + Assert.Equal("agentId", exception.ParamName); + } + /// /// Creates a test AssistantClient implementation for testing. /// @@ -220,6 +468,17 @@ public override ClientResult CreateAssistant(string model, AssistantC { return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!; } + + public override ClientResult GetAssistant(string assistantId, CancellationToken cancellationToken = default) + { + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!; + } + + public override async Task> GetAssistantAsync(string assistantId, CancellationToken cancellationToken = default) + { + await Task.Delay(1, cancellationToken); // Simulate async operation + return ClientResult.FromValue(ModelReaderWriter.Read(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!; + } } private sealed class TestChatClient : DelegatingChatClient diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs index f7ad1ebcdcd..f2b2bcfd6a9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentExtensionsTests.cs @@ -277,6 +277,31 @@ public async Task CreateFromAgent_InvokeWithComplexResponseFromAgentAsync_Return Assert.Equal("Complex response", result.ToString()); } + [Theory] + [InlineData("MyAgent", "MyAgent")] + [InlineData("Agent123", "Agent123")] + [InlineData("Agent_With_Underscores", "Agent_With_Underscores")] + [InlineData("Agent_With_________@@@@_Underscores", "Agent_With_Underscores")] + [InlineData("123Agent", "123Agent")] + [InlineData("My-Agent", "My_Agent")] + [InlineData("My Agent", "My_Agent")] + [InlineData("Agent@123", "Agent_123")] + [InlineData("Agent/With\\Slashes", "Agent_With_Slashes")] + [InlineData("Agent.With.Dots", "Agent_With_Dots")] + public void CreateFromAgent_SanitizesAgentName(string agentName, string expectedFunctionName) + { + // Arrange + var mockAgent = new Mock(); + mockAgent.Setup(a => a.Name).Returns(agentName); + + // Act + var result = mockAgent.Object.AsAIFunction(); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedFunctionName, result.Name); + } + /// /// Test implementation of AIAgent for testing purposes. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs index 71768d83d2c..dc983ef2022 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentOptionsTests.cs @@ -61,9 +61,7 @@ public void ParameterizedConstructor_WithInstructionsOnly_SetsChatOptionsWithIns Assert.Null(options.Name); Assert.Equal(Instructions, options.Instructions); Assert.Null(options.Description); - Assert.NotNull(options.ChatOptions); - Assert.Equal(Instructions, options.ChatOptions.Instructions); - Assert.Null(options.ChatOptions.Tools); + Assert.Null(options.ChatOptions); } [Fact] @@ -107,7 +105,7 @@ public void ParameterizedConstructor_WithInstructionsAndTools_SetsChatOptionsWit Assert.Equal(Instructions, options.Instructions); Assert.Null(options.Description); Assert.NotNull(options.ChatOptions); - Assert.Equal(Instructions, options.ChatOptions.Instructions); + Assert.Null(options.ChatOptions.Instructions); Assert.Same(tools, options.ChatOptions.Tools); } @@ -132,7 +130,7 @@ public void ParameterizedConstructor_WithAllParameters_SetsAllPropertiesCorrectl Assert.Equal(Instructions, options.Instructions); Assert.Equal(Description, options.Description); Assert.NotNull(options.ChatOptions); - Assert.Equal(Instructions, options.ChatOptions.Instructions); + Assert.Null(options.ChatOptions.Instructions); Assert.Same(tools, options.ChatOptions.Tools); } @@ -165,8 +163,13 @@ public void Clone_CreatesDeepCopyWithSameValues() const string Name = "Test name"; const string Description = "Test description"; var tools = new List { AIFunctionFactory.Create(() => "test") }; - static ChatMessageStore ChatMessageStoreFactory(ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock().Object; - static AIContextProvider AIContextProviderFactory(ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => new Mock().Object; + + static ChatMessageStore ChatMessageStoreFactory( + ChatClientAgentOptions.ChatMessageStoreFactoryContext ctx) => new Mock().Object; + + static AIContextProvider AIContextProviderFactory( + ChatClientAgentOptions.AIContextProviderFactoryContext ctx) => + new Mock().Object; var original = new ChatClientAgentOptions(Instructions, Name, Description, tools) { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 9b9f0736aca..f20f7fe082b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -1012,6 +1012,31 @@ public async Task ChatOptionsMergingUsesAgentOptionsWhenRequestHasNoneAsync() Assert.Equal("test instructions", capturedChatOptions.Instructions); } + [Fact] + public async Task ChatOptionsMergingUsesAgentOptionsConstructorWhenRequestHasNoneAsync() + { + Mock mockService = new(); + ChatOptions? capturedChatOptions = null; + mockService.Setup( + s => s.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => + capturedChatOptions = opts) + .ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")])); + + ChatClientAgent agent = new(mockService.Object, options: new("test instructions")); + var messages = new List { new(ChatRole.User, "test") }; + + // Act + await agent.RunAsync(messages); + + // Assert + Assert.NotNull(capturedChatOptions); + Assert.Equal("test instructions", capturedChatOptions.Instructions); + } + /// /// Verify that ChatOptions merging works when request has ChatOptions but agent doesn't. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs index cbcaaf94de4..38773586448 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientBuilderExtensionsTests.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using Microsoft.Agents.AI.ChatClient; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Moq; diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index d8d5c56b0a5..2b68cd41a28 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -567,6 +567,9 @@ async static IAsyncEnumerable CallbackAsync( ] } }, + { + "type": "web_search" + }, { "type": "function", "name": "GetCurrentWeather", diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs new file mode 100644 index 00000000000..d69e856af84 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MenuPlugin.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using Microsoft.Extensions.AI; +using Microsoft.SemanticKernel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; + +public sealed class MenuPlugin +{ + public IEnumerable GetTools() + { + yield return AIFunctionFactory.Create(this.GetMenu, name: $"{nameof(MenuPlugin)}_{nameof(GetMenu)}"); + yield return AIFunctionFactory.Create(this.GetSpecials, name: $"{nameof(MenuPlugin)}_{nameof(GetSpecials)}"); + yield return AIFunctionFactory.Create(this.GetItemPrice, name: $"{nameof(MenuPlugin)}_{nameof(GetItemPrice)}"); + } + + [KernelFunction, Description("Provides a list items on the menu.")] + public MenuItem[] GetMenu() + { + return s_menuItems; + } + + [KernelFunction, Description("Provides a list of specials from the menu.")] + public MenuItem[] GetSpecials() + { + return [.. s_menuItems.Where(i => i.IsSpecial)]; + } + + [KernelFunction, Description("Provides the price of the requested menu item.")] + public float? GetItemPrice( + [Description("The name of the menu item.")] + string name) + { + return s_menuItems.FirstOrDefault(i => i.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Price; + } + + private static readonly MenuItem[] s_menuItems = + [ + new() + { + Category = "Soup", + Name = "Clam Chowder", + Price = 4.95f, + IsSpecial = true, + }, + new() + { + Category = "Soup", + Name = "Tomato Soup", + Price = 4.95f, + IsSpecial = false, + }, + new() + { + Category = "Salad", + Name = "Cobb Salad", + Price = 9.99f, + }, + new() + { + Category = "Salad", + Name = "House Salad", + Price = 4.95f, + }, + new() + { + Category = "Drink", + Name = "Chai Tea", + Price = 2.95f, + IsSpecial = true, + }, + new() + { + Category = "Drink", + Name = "Soda", + Price = 1.95f, + }, + ]; + + public sealed class MenuItem + { + public string Category { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public float Price { get; init; } + public bool IsSpecial { get; init; } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml index a85f3035772..eddcc7b0aaf 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgent.yaml @@ -2,4 +2,4 @@ type: foundry_agent name: BasicAgent description: Basic agent for integration tests model: - id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} + id: ${FOUNDRY_MEDIA_DEPLOYMENT_NAME} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml new file mode 100644 index 00000000000..17cc84b0106 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/ToolAgent.yaml @@ -0,0 +1,15 @@ +type: foundry_agent +name: ToolAgent +description: Agent with a function tool defined. +model: + id: ${FOUNDRY_MODEL_DEPLOYMENT_NAME} +tools: + - id: MenuPlugin_GetMenu + type: function + description: Provides a list items on the menu. + - id: MenuPlugin_GetSpecials + type: function + description: Provides a list of specials from the menu. + - id: MenuPlugin_GetItemPrice + type: function + description: Provides the price of the requested menu item. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs index 45b7735eb96..87b9160196f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/AzureAgentProviderTest.cs @@ -8,21 +8,17 @@ using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Shared.IntegrationTests; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; public sealed class AzureAgentProviderTest(ITestOutputHelper output) : IntegrationTest(output) { - private AzureAIConfiguration? _configuration; - [Fact] public async Task ConversationTestAsync() { // Arrange - AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential()); + AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); // Act string conversationId = await provider.CreateConversationAsync(); // Assert @@ -52,7 +48,7 @@ public async Task ConversationTestAsync() public async Task GetAgentTestAsync() { // Arrange - AzureAgentProvider provider = new(this.Configuration.Endpoint, new AzureCliCredential()); + AzureAgentProvider provider = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); string agentName = $"TestAgent-{DateTime.UtcNow:yyMMdd-HHmmss-fff}"; string agent1Id = await this.CreateAgentAsync(); @@ -74,22 +70,8 @@ public async Task GetAgentTestAsync() private async ValueTask CreateAgentAsync(string? name = null) { - PersistentAgentsClient client = new(this.Configuration.Endpoint, new AzureCliCredential()); - PersistentAgent agent = await client.Administration.CreateAgentAsync(this.Configuration.DeploymentName, name: name); + PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); + PersistentAgent agent = await client.Administration.CreateAgentAsync(this.FoundryConfiguration.DeploymentName, name: name); return agent.Id; } - - private AzureAIConfiguration Configuration - { - get - { - if (this._configuration is null) - { - this._configuration ??= InitializeConfig().GetSection("AzureAI").Get(); - Assert.NotNull(this._configuration); - } - - return this._configuration; - } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs index 0dd9f089405..93623d40ca2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeCodeGenTest.cs @@ -12,10 +12,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Tests execution of workflow created by . /// -[Collection("Global")] public sealed class DeclarativeCodeGenTest(ITestOutputHelper output) : WorkflowTest(output) { [Theory] + [InlineData("CheckSystem.yaml", "CheckSystem.json")] [InlineData("SendActivity.yaml", "SendActivity.json")] [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] @@ -29,11 +29,14 @@ public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, [InlineData("Marketing.yaml", "Marketing.json", true)] [InlineData("MathChat.yaml", "MathChat.json", true)] [InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")] - [InlineData("HumanInLoop.yaml", "HumanInLoop.json", Skip = "Needs template support")] public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration); - protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions) + [Fact(Skip = "Needs template support")] + public Task ValidateMultiTurnAsync() => + this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true); + + protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint) { const string WorkflowNamespace = "Test.WorkflowProviders"; const string WorkflowPrefix = "Test"; @@ -47,15 +50,19 @@ protected override async Task RunAndVerifyAsync(Testcase testcase, strin workflowProviderName: $"{WorkflowPrefix}WorkflowProvider", WorkflowNamespace, workflowOptions, - (TInput)GetInput(testcase)); + input); - WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput(testcase)).ConfigureAwait(false); + WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false); + // Verify no action events are present Assert.Empty(workflowEvents.ActionInvokeEvents); Assert.Empty(workflowEvents.ActionCompleteEvents); - AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase); + // Verify the associated conversations + AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase); + // Verify executor events AssertWorkflow.EventCounts(workflowEvents.ExecutorInvokeEvents.Count - 2, testcase); AssertWorkflow.EventCounts(workflowEvents.ExecutorCompleteEvents.Count - 2, testcase); + // Verify action sequences AssertWorkflow.EventSequence(workflowEvents.ExecutorInvokeEvents.Select(e => e.ExecutorId), testcase); } finally 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 2937444c19a..a57149c015b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/DeclarativeWorkflowTest.cs @@ -12,10 +12,10 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; /// /// Tests execution of workflow created by . /// -[Collection("Global")] public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) { [Theory] + [InlineData("CheckSystem.yaml", "CheckSystem.json")] [InlineData("SendActivity.yaml", "SendActivity.json")] [InlineData("InvokeAgent.yaml", "InvokeAgent.json")] [InlineData("InvokeAgent.yaml", "InvokeAgent.json", true)] @@ -29,27 +29,36 @@ public Task ValidateCaseAsync(string workflowFileName, string testcaseFileName, [InlineData("Marketing.yaml", "Marketing.json", true)] [InlineData("MathChat.yaml", "MathChat.json", true)] [InlineData("DeepResearch.yaml", "DeepResearch.json", Skip = "Long running")] - [InlineData("HumanInLoop.yaml", "HumanInLoop.json")] public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) => this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", workflowFileName), testcaseFileName, externalConveration); - protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions) + [Fact] + public Task ValidateMultiTurnAsync() => + this.RunWorkflowAsync(Path.Combine(GetRepoFolder(), "workflow-samples", "HumanInLoop.yaml"), "HumanInLoop.json", useJsonCheckpoint: true); + + protected override async Task RunAndVerifyAsync(Testcase testcase, string workflowPath, DeclarativeWorkflowOptions workflowOptions, TInput input, bool useJsonCheckpoint) { Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); - WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, (TInput)GetInput(testcase)).ConfigureAwait(false); + WorkflowEvents workflowEvents = await harness.RunTestcaseAsync(testcase, input, useJsonCheckpoint).ConfigureAwait(false); + // Verify executor events are present Assert.NotEmpty(workflowEvents.ExecutorInvokeEvents); Assert.NotEmpty(workflowEvents.ExecutorCompleteEvents); - AssertWorkflow.Conversation(workflowOptions.ConversationId, workflowEvents.ConversationEvents, testcase); + // Verify the associated conversations + AssertWorkflow.Conversation(workflowEvents.ConversationEvents, testcase); + // Verify the agent responses AssertWorkflow.Responses(workflowEvents.AgentResponseEvents, testcase); + // Verify the messages on the workflow conversation await AssertWorkflow.MessagesAsync( GetConversationId(workflowOptions.ConversationId, workflowEvents.ConversationEvents), testcase, workflowOptions.AgentProvider); + // Verify action events AssertWorkflow.EventCounts(workflowEvents.ActionInvokeEvents.Count, testcase); AssertWorkflow.EventCounts(workflowEvents.ActionCompleteEvents.Count, testcase, isCompletion: true); + // Verify action sequences AssertWorkflow.EventSequence(workflowEvents.ActionInvokeEvents.Select(e => e.ActionId), testcase); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs index 6cd29a08546..4ceee44c6be 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/AgentFactory.cs @@ -26,6 +26,7 @@ internal static class AgentFactory new() { ["FOUNDRY_AGENT_TEST"] = "TestAgent.yaml", + ["FOUNDRY_AGENT_TOOL"] = "ToolAgent.yaml", ["FOUNDRY_AGENT_ANSWER"] = "QuestionAgent.yaml", ["FOUNDRY_AGENT_STUDENT"] = "StudentAgent.yaml", ["FOUNDRY_AGENT_TEACHER"] = "TeacherAgent.yaml", @@ -50,6 +51,7 @@ internal static class AgentFactory IKernelBuilder kernelBuilder = Kernel.CreateBuilder(); kernelBuilder.Services.AddSingleton(clientAgents); kernelBuilder.Services.AddSingleton(clientProjects); + kernelBuilder.Plugins.AddFromType(); AgentCreationOptions creationOptions = new() { Kernel = kernelBuilder.Build() }; AzureAIAgentFactory factory = new(); string repoRoot = WorkflowTest.GetRepoFolder(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs index 60aa4f38df6..de83afd723d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/IntegrationTest.cs @@ -1,10 +1,16 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Frozen; +using System.Collections.Generic; using System.Reflection; +using System.Threading.Tasks; +using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; +using Shared.IntegrationTests; using Xunit.Abstractions; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; @@ -14,6 +20,21 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; /// public abstract class IntegrationTest : IDisposable { + private IConfigurationRoot? _configuration; + private AzureAIConfiguration? _foundryConfiguration; + + protected IConfigurationRoot Configuration => this._configuration ??= InitializeConfig(); + + internal AzureAIConfiguration FoundryConfiguration + { + get + { + this._foundryConfiguration ??= this.Configuration.GetSection("AzureAI").Get(); + Assert.NotNull(this._foundryConfiguration); + return this._foundryConfiguration; + } + } + public TestOutputAdapter Output { get; } protected IntegrationTest(ITestOutputHelper output) @@ -47,7 +68,37 @@ protected static void SetProduct() internal static string FormatVariablePath(string variableName, string? scope = null) => $"{scope ?? WorkflowFormulaState.DefaultScopeName}.{variableName}"; - protected static IConfigurationRoot InitializeConfig() => + protected async ValueTask CreateOptionsAsync(bool externalConversation = false, params IEnumerable functionTools) + { + FrozenDictionary agentMap = await AgentFactory.GetAgentsAsync(this.FoundryConfiguration, this.Configuration); + + IConfiguration workflowConfig = + new ConfigurationBuilder() + .AddInMemoryCollection(agentMap) + .Build(); + + AzureAgentProvider agentProvider = + new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()) + { + Functions = functionTools, + }; + + string? conversationId = null; + if (externalConversation) + { + conversationId = await agentProvider.CreateConversationAsync().ConfigureAwait(false); + } + + return + new DeclarativeWorkflowOptions(agentProvider) + { + Configuration = workflowConfig, + ConversationId = conversationId, + LoggerFactory = this.Output + }; + } + + private static IConfigurationRoot InitializeConfig() => new ConfigurationBuilder() .AddJsonFile("appsettings.Development.json", true) .AddEnvironmentVariables() diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs index 797e2548002..4e5e775b928 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowHarness.cs @@ -2,22 +2,26 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; using Shared.Code; +using Xunit.Sdk; namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; internal sealed class WorkflowHarness(Workflow workflow, string runId) { - private readonly CheckpointManager _checkpointManager = CheckpointManager.CreateInMemory(); + private CheckpointManager? _checkpointManager; private CheckpointInfo? LastCheckpoint { get; set; } - public async Task RunTestcaseAsync(Testcase testcase, TInput input) where TInput : notnull + public async Task RunTestcaseAsync(Testcase testcase, TInput input, bool useJson = false) where TInput : notnull { - WorkflowEvents workflowEvents = await this.RunAsync(input); + WorkflowEvents workflowEvents = await this.RunWorkflowAsync(input, useJson); int requestCount = (workflowEvents.InputEvents.Count + 1) / 2; int responseCount = 0; while (requestCount > responseCount) @@ -26,9 +30,8 @@ public async Task RunTestcaseAsync(Testcase testcase, TI Assert.NotEmpty(testcase.Setup.Responses); string inputText = testcase.Setup.Responses[responseCount].Value; Console.WriteLine($"INPUT: {inputText}"); - InputResponse response = new(inputText); ++responseCount; - WorkflowEvents runEvents = await this.ResumeAsync(response).ConfigureAwait(false); + WorkflowEvents runEvents = await this.ResumeAsync(new InputResponse(inputText)).ConfigureAwait(false); workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); requestCount = (workflowEvents.InputEvents.Count + 1) / 2; } @@ -36,20 +39,20 @@ public async Task RunTestcaseAsync(Testcase testcase, TI return workflowEvents; } - private async Task RunAsync(TInput input) where TInput : notnull + public async Task RunWorkflowAsync(TInput input, bool useJson = false) where TInput : notnull { Console.WriteLine("RUNNING WORKFLOW..."); - Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, this._checkpointManager, runId); + Checkpointed run = await InProcessExecution.StreamAsync(workflow, input, this.GetCheckpointManager(useJson), runId); IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run).ToArrayAsync(); this.LastCheckpoint = workflowEvents.OfType().LastOrDefault()?.CompletionInfo?.Checkpoint; return new WorkflowEvents(workflowEvents); } - private async Task ResumeAsync(InputResponse response) + public async Task ResumeAsync(object response) { - Console.WriteLine("RESUMING WORKFLOW..."); + Console.WriteLine("\nRESUMING WORKFLOW..."); Assert.NotNull(this.LastCheckpoint); - Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this._checkpointManager, runId); + Checkpointed run = await InProcessExecution.ResumeStreamAsync(workflow, this.LastCheckpoint, this.GetCheckpointManager(), runId); IReadOnlyList workflowEvents = await MonitorAndDisposeWorkflowRunAsync(run, response).ToArrayAsync(); return new WorkflowEvents(workflowEvents); } @@ -75,7 +78,22 @@ public static async Task GenerateCodeAsync( return new WorkflowHarness(workflow, runId); } - private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, InputResponse? response = null) + private CheckpointManager GetCheckpointManager(bool useJson = false) + { + if (useJson && this._checkpointManager is null) + { + DirectoryInfo checkpointFolder = Directory.CreateDirectory(Path.Combine(".", $"chk-{DateTime.Now:yyMMdd-hhmmss-ff}")); + this._checkpointManager = CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(checkpointFolder)); + } + else + { + this._checkpointManager ??= CheckpointManager.CreateInMemory(); + } + + return this._checkpointManager; + } + + private static async IAsyncEnumerable MonitorAndDisposeWorkflowRunAsync(Checkpointed run, object? response = null) { await using IAsyncDisposable disposeRun = run; @@ -98,9 +116,39 @@ private static async IAsyncEnumerable MonitorAndDisposeWorkflowRu exitLoop = true; } break; + + case ConversationUpdateEvent conversationEvent: + Console.WriteLine($"CONVERSATION: {conversationEvent.ConversationId}"); + break; + + case ExecutorFailedEvent failureEvent: + Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}"); + break; + + case WorkflowErrorEvent errorEvent: + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); + + case ExecutorInvokedEvent executorInvokeEvent: + Console.WriteLine($"EXEC: {executorInvokeEvent.ExecutorId}"); + break; + case DeclarativeActionInvokedEvent actionInvokeEvent: Console.WriteLine($"ACTION: {actionInvokeEvent.ActionId} [{actionInvokeEvent.ActionType}]"); break; + + case AgentRunResponseEvent responseEvent: + if (!string.IsNullOrEmpty(responseEvent.Response.Text)) + { + Console.WriteLine($"AGENT: {responseEvent.Response.AgentId}: {responseEvent.Response.Text}"); + } + else + { + foreach (FunctionCallContent toolCall in responseEvent.Response.Messages.SelectMany(m => m.Contents.OfType())) + { + Console.WriteLine($"TOOL: {toolCall.Name} [{responseEvent.Response.AgentId}]"); + } + } + break; } yield return workflowEvent; @@ -111,6 +159,6 @@ private static async IAsyncEnumerable MonitorAndDisposeWorkflowRu } } - Console.WriteLine("SUSPENDING WORKFLOW..."); + Console.WriteLine("SUSPENDING WORKFLOW...\n"); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index a3ad1a59835..36493551824 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -1,17 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Frozen; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.Identity; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Configuration; -using Shared.IntegrationTests; using Xunit.Abstractions; using Xunit.Sdk; @@ -25,65 +21,41 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o protected abstract Task RunAndVerifyAsync( Testcase testcase, string workflowPath, - DeclarativeWorkflowOptions workflowOptions) where TInput : notnull; + DeclarativeWorkflowOptions workflowOptions, + TInput input, + bool useJsonCheckpoint) where TInput : notnull; protected Task RunWorkflowAsync( string workflowPath, string testcaseFileName, - bool externalConversation = false) + bool externalConversation = false, + bool useJsonCheckpoint = false) { this.Output.WriteLine($"WORKFLOW: {workflowPath}"); this.Output.WriteLine($"TESTCASE: {testcaseFileName}"); Testcase testcase = ReadTestcase(testcaseFileName); - IConfiguration configuration = InitializeConfig(); this.Output.WriteLine($" {testcase.Description}"); return testcase.Setup.Input.Type switch { - nameof(ChatMessage) => this.TestWorkflowAsync(testcase, workflowPath, configuration), - nameof(String) => this.TestWorkflowAsync(testcase, workflowPath, configuration), + nameof(ChatMessage) => TestWorkflowAsync(), + nameof(String) => TestWorkflowAsync(), _ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."), }; - } - protected async Task TestWorkflowAsync( - Testcase testcase, - string workflowPath, - IConfiguration configuration, - bool externalConversation = false) where TInput : notnull - { - this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}"); - - AzureAIConfiguration? foundryConfig = configuration.GetSection("AzureAI").Get(); - Assert.NotNull(foundryConfig); - - FrozenDictionary agentMap = await AgentFactory.GetAgentsAsync(foundryConfig, configuration); + async Task TestWorkflowAsync() where TInput : notnull + { + this.Output.WriteLine($"INPUT: {testcase.Setup.Input.Value}"); - IConfiguration workflowConfig = - new ConfigurationBuilder() - .AddInMemoryCollection(agentMap) - .Build(); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation).ConfigureAwait(false); - AzureAgentProvider agentProvider = new(foundryConfig.Endpoint, new AzureCliCredential()); + TInput input = (TInput)GetInput(testcase); - string? conversationId = null; - if (externalConversation) - { - conversationId = await agentProvider.CreateConversationAsync().ConfigureAwait(false); + await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions, input, useJsonCheckpoint); } - - DeclarativeWorkflowOptions workflowOptions = - new(agentProvider) - { - Configuration = workflowConfig, - ConversationId = conversationId, - LoggerFactory = this.Output - }; - - await this.RunAndVerifyAsync(testcase, workflowPath, workflowOptions); } protected static string? GetConversationId(string? conversationId, IReadOnlyList conversationEvents) @@ -101,7 +73,15 @@ protected async Task TestWorkflowAsync( return null; } - protected static object GetInput(Testcase testcase) where TInput : notnull => + protected static Testcase ReadTestcase(string testcaseFileName) + { + string testcaseJson = File.ReadAllText(Path.Combine("Testcases", testcaseFileName)); + Testcase? testcase = JsonSerializer.Deserialize(testcaseJson, s_jsonSerializerOptions); + Assert.NotNull(testcase); + return testcase; + } + + private static object GetInput(Testcase testcase) where TInput : notnull => testcase.Setup.Input.Type switch { nameof(ChatMessage) => new ChatMessage(ChatRole.User, testcase.Setup.Input.Value), @@ -109,14 +89,6 @@ protected static object GetInput(Testcase testcase) where TInput : notnu _ => throw new NotSupportedException($"Input type '{testcase.Setup.Input.Type}' is not supported."), }; - protected static Testcase ReadTestcase(string testcaseFileName) - { - using Stream testcaseStream = File.Open(Path.Combine("Testcases", testcaseFileName), FileMode.Open); - Testcase? testcase = JsonSerializer.Deserialize(testcaseStream, s_jsonSerializerOptions); - Assert.NotNull(testcase); - return testcase; - } - internal static string GetRepoFolder() { DirectoryInfo? current = new(Directory.GetCurrentDirectory()); @@ -136,16 +108,9 @@ internal static string GetRepoFolder() protected static class AssertWorkflow { - public static void Conversation(string? conversationId, IReadOnlyList conversationEvents, Testcase testcase) + public static void Conversation(IReadOnlyList conversationEvents, Testcase testcase) { - if (string.IsNullOrEmpty(conversationId)) - { - Assert.Equal(testcase.Validation.ConversationCount, conversationEvents.Count); - } - else - { - Assert.Equal(testcase.Validation.ConversationCount - 1, conversationEvents.Count); - } + Assert.Equal(testcase.Validation.ConversationCount, conversationEvents.Count); } // "isCompletion" adjusts validation logic to account for when condition completion is not experienced due to goto. Remove this test logic once addressed. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs new file mode 100644 index 00000000000..280df88b4b7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/MediaInputTest.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; +using Azure.AI.Agents.Persistent; +using Azure.Identity; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; + +/// +/// Tests execution of workflow created by . +/// +public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(output) +{ + private const string WorkflowFileName = "MediaInput.yaml"; + private const string ImageReference = "https://upload.wikimedia.org/wikipedia/commons/5/56/White_shark.jpg"; + + [Fact(Skip = "Service issue prevents this simple case")] + public async Task ValidateImageUrlAsync() + { + this.Output.WriteLine($"Image: {ImageReference}"); + await this.ValidateImageAsync(new UriContent(ImageReference, "image/jpeg")); + } + + [Fact] + public async Task ValidateImageDataAsync() + { + byte[] imageData = await DownloadImageAsync(); + string encodedData = Convert.ToBase64String(imageData); + string imageUrl = $"data:image/png;base64,{encodedData}"; + this.Output.WriteLine($"Image: {imageUrl.Substring(0, 112)}..."); + await this.ValidateImageAsync(new DataContent(imageUrl)); + } + + [Fact] + public async Task ValidateImageUploadAsync() + { + byte[] imageData = await DownloadImageAsync(); + PersistentAgentsClient client = new(this.FoundryConfiguration.Endpoint, new AzureCliCredential()); + using MemoryStream contentStream = new(imageData); + PersistentAgentFileInfo fileInfo = await client.Files.UploadFileAsync(contentStream, PersistentAgentFilePurpose.Agents, "image.jpg"); + try + { + this.Output.WriteLine($"Image: {fileInfo.Id}"); + await this.ValidateImageAsync(new HostedFileContent(fileInfo.Id)); + } + finally + { + await client.Files.DeleteFileAsync(fileInfo.Id); + } + } + + private static async Task DownloadImageAsync() + { + using HttpClient client = new(); + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0"); + return await client.GetByteArrayAsync(new Uri(ImageReference)); + } + + private async Task ValidateImageAsync(AIContent imageContent) + { + ChatMessage inputMessage = new(ChatRole.User, [new TextContent("Here is my image:"), imageContent]); + + DeclarativeWorkflowOptions options = await this.CreateOptionsAsync(); + Workflow workflow = DeclarativeWorkflowBuilder.Build(Path.Combine(Environment.CurrentDirectory, "Workflows", WorkflowFileName), options); + + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(WorkflowFileName)); + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync(inputMessage).ConfigureAwait(false); + Assert.Single(workflowEvents.ConversationEvents); + this.Output.WriteLine("CONVERSATION: " + workflowEvents.ConversationEvents[0].ConversationId); + Assert.Single(workflowEvents.AgentResponseEvents); + this.Output.WriteLine("RESPONSE: " + workflowEvents.AgentResponseEvents[0].Response.Text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/CheckSystem.json b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/CheckSystem.json new file mode 100644 index 00000000000..2e7d4b6f8d0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Testcases/CheckSystem.json @@ -0,0 +1,24 @@ +{ + "description": "Send an activity message.", + "setup": { + "input": { + "type": "String", + "value": "Everything good?" + } + }, + "validation": { + "conversation_count": 1, + "min_action_count": 2, + "max_action_count": -1, + "min_response_count": 0, + "actions": { + "start": [ + "check_system" + ], + "final": [ + "activity_passed", + "check_system_Post" + ] + } + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs new file mode 100644 index 00000000000..995a92bfc3a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/ToolInputWorkflowTest.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Agents; +using Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.Framework; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests; + +/// +/// Tests execution of workflow created by . +/// +public sealed class ToolInputWorkflowTest(ITestOutputHelper output) : IntegrationTest(output) +{ + [Fact] + public Task ValidateAutoInvokeAsync() => + this.RunWorkflowAsync(autoInvoke: true, new MenuPlugin().GetTools()); + + [Fact] + public Task ValidateRequestInvokeAsync() => + this.RunWorkflowAsync(autoInvoke: false, new MenuPlugin().GetTools()); + + private static string GetWorkflowPath(string workflowFileName) => Path.Combine(Environment.CurrentDirectory, "Workflows", workflowFileName); + + private async Task RunWorkflowAsync(bool autoInvoke, params IEnumerable functionTools) + { + string workflowPath = GetWorkflowPath("FunctionTool.yaml"); + Dictionary functionMap = autoInvoke ? [] : functionTools.ToDictionary(tool => tool.Name, tool => tool); + DeclarativeWorkflowOptions workflowOptions = await this.CreateOptionsAsync(externalConversation: false, autoInvoke ? functionTools : []); + Workflow workflow = DeclarativeWorkflowBuilder.Build(workflowPath, workflowOptions); + + WorkflowHarness harness = new(workflow, runId: Path.GetFileNameWithoutExtension(workflowPath)); + WorkflowEvents workflowEvents = await harness.RunWorkflowAsync("hi!").ConfigureAwait(false); + int requestCount = (workflowEvents.InputEvents.Count + 1) / 2; + int responseCount = 0; + while (requestCount > responseCount) + { + Assert.False(autoInvoke); + + RequestInfoEvent inputEvent = workflowEvents.InputEvents[workflowEvents.InputEvents.Count - 1]; + AgentToolRequest? toolRequest = inputEvent.Request.Data.As(); + Assert.NotNull(toolRequest); + + List<(FunctionCallContent, AIFunction)> functionCalls = []; + foreach (FunctionCallContent functionCall in toolRequest.FunctionCalls) + { + this.Output.WriteLine($"TOOL REQUEST: {functionCall.Name}"); + if (!functionMap.TryGetValue(functionCall.Name, out AIFunction? functionTool)) + { + Assert.Fail($"TOOL FAILURE [{functionCall.Name}] - MISSING"); + return; + } + functionCalls.Add((functionCall, functionTool)); + } + + IList functionResults = await InvokeToolsAsync(functionCalls); + + ++responseCount; + + WorkflowEvents runEvents = await harness.ResumeAsync(AgentToolResponse.Create(toolRequest, functionResults)).ConfigureAwait(false); + workflowEvents = new WorkflowEvents([.. workflowEvents.Events, .. runEvents.Events]); + } + + if (autoInvoke) + { + Assert.Empty(workflowEvents.InputEvents); + } + else + { + Assert.NotEmpty(workflowEvents.InputEvents); + } + + Assert.Equal(autoInvoke ? 3 : 5, workflowEvents.AgentResponseEvents.Count); + Assert.All(workflowEvents.AgentResponseEvents, response => response.Response.Text.Contains("4.95")); + } + + private static async ValueTask> InvokeToolsAsync(IEnumerable<(FunctionCallContent, AIFunction)> functionCalls) + { + List results = []; + + foreach ((FunctionCallContent functionCall, AIFunction functionTool) in functionCalls) + { + AIFunctionArguments? functionArguments = functionCall.Arguments is null ? null : new(functionCall.Arguments.NormalizePortableValues()); + object? result = await functionTool.InvokeAsync(functionArguments).ConfigureAwait(false); + results.Add(new FunctionResultContent(functionCall.CallId, JsonSerializer.Serialize(result))); + } + + return results; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml new file mode 100644 index 00000000000..c3542fb057d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/CheckSystem.yaml @@ -0,0 +1,57 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: ConditionGroup + id: check_system + conditions: + + - condition: =IsBlank(System.Conversation) + id: conversation_check + actions: + - kind: EndDialog + id: conversation_bad + + - condition: =IsBlank(System.Conversation.Id) + id: conversation_id_check1 + actions: + - kind: EndDialog + id: conversation_id_bad1 + + - condition: =IsBlank(System.ConversationId) + id: conversation_id_check2 + actions: + - kind: EndDialog + id: conversation_id_bad2 + + - condition: =IsBlank(System.LastMessage) + id: message_check + actions: + - kind: EndDialog + id: message_bad + + - condition: =IsBlank(System.LastMessage.Id) + id: message_id_check1 + actions: + - kind: EndDialog + id: message_id_bad1 + + - condition: =IsBlank(System.LastMessageId) + id: message_id_check2 + actions: + - kind: EndDialog + id: message_id_bad2 + + - condition: =IsBlank(System.LastMessageText) + id: message_text_check + actions: + - kind: EndDialog + id: message_text_bad + + elseActions: + - kind: SendActivity + id: activity_passed + activity: PASSED! diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml new file mode 100644 index 00000000000..8cb65db8d6d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/FunctionTool.yaml @@ -0,0 +1,28 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: InvokeAzureAgent + id: invoke_greet + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_TOOL + + - kind: InvokeAzureAgent + id: invoke_menu + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_TOOL + input: + messages: =UserMessage("What's on today's menu?") + + - kind: InvokeAzureAgent + id: invoke_item + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_TOOL + input: + messages: =UserMessage("How much is the clam chowder?") diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml new file mode 100644 index 00000000000..c2a428f6d45 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Workflows/MediaInput.yaml @@ -0,0 +1,16 @@ +kind: Workflow +trigger: + + kind: OnConversationStart + id: workflow_test + actions: + + - kind: InvokeAzureAgent + id: invoke_vision + conversationId: =System.ConversationId + agent: + name: =Env.FOUNDRY_AGENT_TEST + input: + additionalInstructions: |- + Describe the image contained in the user request, if any; + otherwise, suggest that the user provide an image. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index ba77761906f..7d7aee5418d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.AI; using Moq; using Xunit.Abstractions; +using Xunit.Sdk; namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; @@ -21,7 +22,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; /// public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : WorkflowTest(output) { - private List WorkflowEvents { get; set; } = []; + private List WorkflowEvents { get; } = []; private Dictionary WorkflowEventCounts { get; set; } = []; @@ -214,7 +215,7 @@ public void UnsupportedAction(Type type) AdaptiveDialog dialog = dialogBuilder.Build(); WorkflowFormulaState state = new(RecalcEngineFactory.Create()); - Mock mockAgentProvider = CreateMockProvider(); + Mock mockAgentProvider = CreateMockProvider("1"); DeclarativeWorkflowOptions options = new(mockAgentProvider.Object); WorkflowActionVisitor visitor = new(new DeclarativeWorkflowExecutor(WorkflowActionVisitor.Steps.Root("anything"), options, state, (message) => DeclarativeWorkflowBuilder.DefaultTransform(message)), state, options); WorkflowElementWalker walker = new(visitor); @@ -254,46 +255,57 @@ private Task RunWorkflowAsync(string workflowPath) => private async Task RunWorkflowAsync(string workflowPath, TInput workflowInput) where TInput : notnull { using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath)); - Mock mockAgentProvider = CreateMockProvider(); + Mock mockAgentProvider = CreateMockProvider($"{workflowInput}"); DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output }; Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput); - this.WorkflowEvents = run.WatchStreamAsync().ToEnumerable().ToList(); - foreach (WorkflowEvent workflowEvent in this.WorkflowEvents) + await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync()) { - if (workflowEvent is ExecutorInvokedEvent invokeEvent) - { - ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult; - this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); - } - else if (workflowEvent is DeclarativeActionInvokedEvent actionInvokeEvent) - { - this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}"); - } - else if (workflowEvent is DeclarativeActionCompletedEvent actionCompleteEvent) - { - this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}"); - } - else if (workflowEvent is MessageActivityEvent activityEvent) - { - this.Output.WriteLine($"ACTIVITY: {activityEvent.Message}"); - } - else if (workflowEvent is AgentRunResponseEvent messageEvent) + this.WorkflowEvents.Add(workflowEvent); + + switch (workflowEvent) { - this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}"); + case ExecutorInvokedEvent invokeEvent: + ActionExecutorResult? message = invokeEvent.Data as ActionExecutorResult; + this.Output.WriteLine($"EXEC: {invokeEvent.ExecutorId} << {message?.ExecutorId ?? "?"} [{message?.Result ?? "-"}]"); + break; + + case DeclarativeActionInvokedEvent actionInvokeEvent: + this.Output.WriteLine($"ACTION ENTER: {actionInvokeEvent.ActionId}"); + break; + + case DeclarativeActionCompletedEvent actionCompleteEvent: + this.Output.WriteLine($"ACTION EXIT: {actionCompleteEvent.ActionId}"); + break; + + case MessageActivityEvent activityEvent: + this.Output.WriteLine($"ACTIVITY: {activityEvent.Message}"); + break; + + case AgentRunResponseEvent messageEvent: + this.Output.WriteLine($"MESSAGE: {messageEvent.Response.Messages[0].Text.Trim()}"); + break; + + case ExecutorFailedEvent failureEvent: + Console.WriteLine($"Executor failed [{failureEvent.ExecutorId}]: {failureEvent.Data?.Message ?? "Unknown"}"); + break; + + case WorkflowErrorEvent errorEvent: + throw errorEvent.Data as Exception ?? new XunitException("Unexpected failure..."); } } + this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count()); } - private static Mock CreateMockProvider() + private static Mock CreateMockProvider(string input) { Mock mockAgentProvider = new(MockBehavior.Strict); mockAgentProvider.Setup(provider => provider.CreateConversationAsync(It.IsAny())).Returns(() => Task.FromResult(Guid.NewGuid().ToString("N"))); - mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, "Hi!"))); + mockAgentProvider.Setup(provider => provider.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ChatMessage(ChatRole.Assistant, input))); return mockAgentProvider; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs new file mode 100644 index 00000000000..61be304bd46 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolRequestTest.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Base class for event tests. +/// +public sealed class AgentToolRequestTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerialization() + { + AgentToolRequest copy = + VerifyEventSerialization( + new AgentToolRequest( + "agent", + [ + new FunctionCallContent("call1", "result1"), + new FunctionCallContent("call2", "result2", new Dictionary() { { "name", "Clam Chowder" } }) + ])); + Assert.Equal("agent", copy.AgentName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs new file mode 100644 index 00000000000..f6258cb33ea --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/AgentToolResponseTest.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Extensions.AI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Base class for event tests. +/// +public sealed class AgentToolResponseTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerialization() + { + AgentToolResponse copy = + VerifyEventSerialization( + new AgentToolResponse( + "agent", + [ + new FunctionResultContent("call1", "result1"), + new FunctionResultContent("call2", "result2") + ])); + Assert.Equal("agent", copy.AgentName); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs new file mode 100644 index 00000000000..0f573aba7ec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/EventTest.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests; + +/// +/// Base class for event tests. +/// +public abstract class EventTest(ITestOutputHelper output) : WorkflowTest(output) +{ + protected static TEvent VerifyEventSerialization(TEvent source) + { + string? text = JsonSerializer.Serialize(source); + Assert.NotNull(text); + TEvent? copy = JsonSerializer.Deserialize(text); + Assert.NotNull(copy); + return copy; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs new file mode 100644 index 00000000000..d9242307dc0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputRequest.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Base class for event tests. +/// +public sealed class InputRequestTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerialization() + { + InputRequest copy = VerifyEventSerialization(new InputRequest("wassup")); + Assert.Equal("wassup", copy.Prompt); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs new file mode 100644 index 00000000000..6304aef69b9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/InputResponse.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Events; + +/// +/// Base class for event tests. +/// +public sealed class InputResponseTest(ITestOutputHelper output) : EventTest(output) +{ + [Fact] + public void VerifySerialization() + { + InputResponse copy = VerifyEventSerialization(new InputResponse("test response")); + Assert.Equal("test response", copy.Value); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs new file mode 100644 index 00000000000..97649614676 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/PortableValueExtensionsTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Extensions; + +public sealed class PortableValueExtensionsTests +{ + [Fact] + public void InvalidType() => TestInvalidType(IPAddress.Loopback); + + [Fact] + public void NullType() => TestValidType(null, FormulaType.Blank); + + [Fact] + public void BooleanType() => TestValidType(true, FormulaType.Boolean); + + [Fact] + public void StringType() => TestValidType("Hello, World!", FormulaType.String); + + [Fact] + public void IntType() => TestValidType(int.MinValue, FormulaType.Decimal); + + [Fact] + public void LongType() => TestValidType(long.MaxValue, FormulaType.Decimal); + + [Fact] + public void DecimalType() => TestValidType(decimal.MaxValue, FormulaType.Decimal); + + [Fact] + public void FloatType() => TestValidType(float.MaxValue, FormulaType.Number); + + [Fact] + public void DoubleType() => TestValidType(double.MinValue, FormulaType.Number); + + [Fact] + public void DateType() => TestValidType(DateTime.UtcNow.Date, FormulaType.Date); + + [Fact] + public void DateTimeType() => TestValidType(DateTime.UtcNow, FormulaType.DateTime); + + [Fact] + public void TimeSpanType() => TestValidType(DateTime.UtcNow.TimeOfDay, FormulaType.Time); + + [Fact] + public void ChatMessageType() => TestValidType(new ChatMessage(ChatRole.User, "input"), RecordType.Empty()); + + [Fact] + public void ListSimpleType() + { + TableValue convertedValue = (TableValue)TestValidType(new List { 1, 2, 3 }, TableType.Empty()); + Assert.Equal(3, convertedValue.Count()); + RecordValue firstElement = convertedValue.Rows.First().Value; + NamedValue recordElement = Assert.Single(firstElement.Fields); + Assert.Equal("Value", recordElement.Name); + DecimalValue recordValue = Assert.IsType(recordElement.Value); + Assert.Equal(1, recordValue.Value); + } + + [Fact] + public void ListComplexType() + { + TableValue convertedValue = (TableValue)TestValidType(new List { new(ChatRole.User, "input"), new(ChatRole.Assistant, "output") }, TableType.Empty()); + Assert.Equal(2, convertedValue.Count()); + RecordValue firstElement = convertedValue.Rows.First().Value; + StringValue typeValue = Assert.IsType(firstElement.GetField(TypeSchema.Discriminator)); + Assert.Equal(nameof(ChatMessage), typeValue.Value); + StringValue textValue = Assert.IsType(firstElement.GetField(TypeSchema.Message.Fields.Text)); + Assert.Equal("input", textValue.Value); + } + + [Fact] + public void DictionaryType() + { + RecordValue convertedValue = (RecordValue)TestValidType(new Dictionary { { "A", 1 }, { "B", 2 } }, RecordType.Empty()); + Assert.Equal(2, convertedValue.Fields.Count()); + NamedValue firstElement = convertedValue.Fields.First(); + Assert.Equal("A", firstElement.Name); + DecimalValue firstElementValue = Assert.IsType(firstElement.Value); + Assert.Equal(1, firstElementValue.Value); + } + + [Fact] + public void ObjectType() + { + RecordValue convertedValue = (RecordValue)TestValidType(FormulaValue.NewRecordFromFields(new NamedValue("key", FormulaValue.New(3))).ToDataValue().ToObject(), RecordType.Empty()); + Assert.Single(convertedValue.Fields); + NamedValue firstElement = convertedValue.Fields.First(); + Assert.Equal("key", firstElement.Name); + DecimalValue firstElementValue = Assert.IsType(firstElement.Value); + Assert.Equal(3, firstElementValue.Value); + } + + private static void TestInvalidType(object? sourceValue) + { + Assert.Throws(() => sourceValue.AsPortable()); + + PortableValue portableValue = new(sourceValue ?? UnassignedValue.Instance); + Assert.Throws(() => portableValue.ToFormula()); + } + + private static FormulaValue TestValidType(TValue? sourceValue, FormulaType expectedType) where TValue : notnull + { + object portableObject = sourceValue.AsPortable(); + Assert.IsNotType(portableObject); + PortableValue portableValue = new(portableObject); + FormulaValue formulaValue = portableValue.ToFormula(); + Assert.NotNull(formulaValue); + Assert.Equal(expectedType.GetType(), formulaValue.Type.GetType()); + return formulaValue; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs index 62f9231e680..9b667b55f79 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/WorkflowActionExecutorTest.cs @@ -2,10 +2,10 @@ using System; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Interpreter; -using Microsoft.Agents.AI.Workflows.Declarative.Kit; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; using Microsoft.Bot.ObjectModel; using Microsoft.PowerFx.Types; @@ -70,7 +70,7 @@ protected static TAction AssignParent(DialogAction.Builder actionBuilde internal sealed class TestWorkflowExecutor() : Executor("test_workflow") { - public override async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context) => - await context.SendMessageAsync(new ActionExecutorResult(this.Id)).ConfigureAwait(false); + public override async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context, CancellationToken cancellationToken) => + await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 8a534269f04..a96f130a6c6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -6,6 +6,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -20,7 +21,7 @@ public class AgentWorkflowBuilderTests [Fact] public void BuildSequential_InvalidArguments_Throws() { - Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(null!)); + Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential(workflowName: null!, null!)); Assert.Throws("agents", () => AgentWorkflowBuilder.BuildSequential()); } @@ -56,24 +57,24 @@ public void BuildGroupChat_InvalidArguments_Throws() { Assert.Throws("managerFactory", () => AgentWorkflowBuilder.CreateGroupChatBuilderWith(null!)); - var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); + var groupChat = AgentWorkflowBuilder.CreateGroupChatBuilderWith(_ => new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")])); Assert.NotNull(groupChat); Assert.Throws("agents", () => groupChat.AddParticipants(null!)); Assert.Throws("agents", () => groupChat.AddParticipants([null!])); Assert.Throws("agents", () => groupChat.AddParticipants(new DoubleEchoAgent("a1"), null!)); - Assert.Throws("agents", () => new AgentWorkflowBuilder.RoundRobinGroupChatManager(null!)); + Assert.Throws("agents", () => new RoundRobinGroupChatManager(null!)); } [Fact] public void GroupChatManager_MaximumIterationCount_Invalid_Throws() { - var manager = new AgentWorkflowBuilder.RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); + var manager = new RoundRobinGroupChatManager([new DoubleEchoAgent("a1")]); const int DefaultMaxIterations = 40; Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); - Assert.Throws("value", () => manager.MaximumIterationCount = 0); - Assert.Throws("value", () => manager.MaximumIterationCount = -1); + Assert.Throws("value", void () => manager.MaximumIterationCount = 0); + Assert.Throws("value", void () => manager.MaximumIterationCount = -1); Assert.Equal(DefaultMaxIterations, manager.MaximumIterationCount); manager.MaximumIterationCount = 30; @@ -159,7 +160,7 @@ public override async IAsyncEnumerable RunStreamingAsync private sealed class DoubleEchoAgentThread() : InMemoryAgentThread(); - [Fact(Skip = "issue #1109")] + [Fact] public async Task BuildConcurrent_AgentsRunInParallelAsync() { StrongBox> barrier = new(); @@ -182,10 +183,10 @@ public async Task BuildConcurrent_AgentsRunInParallelAsync() // TODO: https://github.com/microsoft/agent-framework/issues/784 // These asserts are flaky until we guarantee message delivery order. - //Assert.Single(Regex.Matches(updateText, "agent1")); - //Assert.Single(Regex.Matches(updateText, "agent2")); - //Assert.Equal(4, Regex.Matches(updateText, "abc").Count); - //Assert.Equal(2, result.Count); + Assert.Single(Regex.Matches(updateText, "agent1")); + Assert.Single(Regex.Matches(updateText, "agent2")); + Assert.Equal(4, Regex.Matches(updateText, "abc").Count); + Assert.Equal(2, result.Count); } } @@ -343,7 +344,7 @@ public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) { const int NumAgents = 3; - var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) + var workflow = AgentWorkflowBuilder.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxIterations }) .AddParticipants(new DoubleEchoAgent("agent1"), new DoubleEchoAgent("agent2")) .AddParticipants(new DoubleEchoAgent("agent3")) .Build(); @@ -385,7 +386,7 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) { StringBuilder sb = new(); - await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, input); + await using StreamingRun run = await InProcessExecution.Lockstep.StreamAsync(workflow, input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); WorkflowOutputEvent? output = null; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs new file mode 100644 index 00000000000..a689baee518 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatProtocolExecutorTests.cs @@ -0,0 +1,232 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Tests for to verify message routing behavior. +/// +public class ChatProtocolExecutorTests +{ + private sealed class TestChatProtocolExecutor : ChatProtocolExecutor + { + public List ReceivedMessages { get; } = []; + public int TurnCount { get; private set; } + + public TestChatProtocolExecutor(string id = "test-executor", ChatProtocolExecutorOptions? options = null) + : base(id, options) + { + } + + protected override async ValueTask TakeTurnAsync( + List messages, + IWorkflowContext context, + bool? emitEvents, + CancellationToken cancellationToken = default) + { + this.ReceivedMessages.AddRange(messages); + this.TurnCount++; + + // Send messages back to context so they can be collected + await context.SendMessageAsync(messages, cancellationToken: cancellationToken); + } + } + + [Fact] + public async Task ChatProtocolExecutor_Handles_ListOfChatMessagesAsync() + { + // Arrange + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + List messages = + [ + new ChatMessage(ChatRole.User, "Hello"), + new ChatMessage(ChatRole.User, "World") + ]; + + // Act - Send List via ExecuteAsync + await executor.ExecuteAsync(messages, new TypeId(typeof(List)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + // Assert + executor.ReceivedMessages.Should().HaveCount(2); + executor.ReceivedMessages[0].Text.Should().Be("Hello"); + executor.ReceivedMessages[1].Text.Should().Be("World"); + executor.TurnCount.Should().Be(1); + } + + [Fact] + public async Task ChatProtocolExecutor_Handles_ArrayOfChatMessagesAsync() + { + // Arrange + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + ChatMessage[] messages = + [ + new ChatMessage(ChatRole.System, "System message"), + new ChatMessage(ChatRole.User, "User query"), + new ChatMessage(ChatRole.Assistant, "Agent reply") + ]; + + // Act - Send as ChatMessage[] + await executor.ExecuteAsync(messages, new TypeId(typeof(ChatMessage[])), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + // Assert + executor.ReceivedMessages.Should().HaveCount(3); + executor.ReceivedMessages[0].Role.Should().Be(ChatRole.System); + executor.ReceivedMessages[1].Role.Should().Be(ChatRole.User); + executor.ReceivedMessages[2].Role.Should().Be(ChatRole.Assistant); + executor.TurnCount.Should().Be(1); + } + + [Fact] + public async Task ChatProtocolExecutor_Handles_SingleChatMessageAsync() + { + // Arrange + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + var message = new ChatMessage(ChatRole.User, "Single message"); + + // Act - Send as single ChatMessage + await executor.ExecuteAsync(message, new TypeId(typeof(ChatMessage)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + // Assert + executor.ReceivedMessages.Should().HaveCount(1); + executor.ReceivedMessages[0].Text.Should().Be("Single message"); + executor.TurnCount.Should().Be(1); + } + + [Fact] + public async Task ChatProtocolExecutor_AccumulatesAndClearsMessagesPerTurnAsync() + { + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + // Send multiple message batches before taking a turn + await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context); + await executor.ExecuteAsync(new List + { + new(ChatRole.User, "Message 2"), + new(ChatRole.User, "Message 3") + }, new TypeId(typeof(List)), context); + await executor.ExecuteAsync(new ChatMessage[] { new(ChatRole.User, "Message 4") }, new TypeId(typeof(ChatMessage[])), context); + + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(4); + executor.ReceivedMessages.Select(m => m.Text).Should().Equal("Message 1", "Message 2", "Message 3", "Message 4"); + executor.TurnCount.Should().Be(1); + + executor.ReceivedMessages.Clear(); + + // Second turn should process new messages only + await executor.ExecuteAsync(new List + { + new(ChatRole.User, "Second batch") + }, new TypeId(typeof(List)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(1); + executor.ReceivedMessages[0].Text.Should().Be("Second batch"); + executor.TurnCount.Should().Be(2); + } + + [Fact] + public async Task ChatProtocolExecutor_WithStringRole_ConvertsStringToMessageAsync() + { + var executor = new TestChatProtocolExecutor( + options: new ChatProtocolExecutorOptions + { + StringMessageChatRole = ChatRole.User + }); + var context = new TestWorkflowContext(executor.Id); + + await executor.ExecuteAsync("String message", new TypeId(typeof(string)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(1); + executor.ReceivedMessages[0].Role.Should().Be(ChatRole.User); + executor.ReceivedMessages[0].Text.Should().Be("String message"); + } + + [Fact] + public async Task ChatProtocolExecutor_EmptyCollection_HandledCorrectlyAsync() + { + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + await executor.ExecuteAsync(new List(), new TypeId(typeof(List)), context); + await executor.ExecuteAsync(Array.Empty(), new TypeId(typeof(ChatMessage[])), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().BeEmpty(); + executor.TurnCount.Should().Be(1); + } + + [Theory] + [InlineData(typeof(List))] + [InlineData(typeof(ChatMessage[]))] + public async Task ChatProtocolExecutor_RoutesCollectionTypesAsync(Type collectionType) + { + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + var sourceMessages = new[] { new ChatMessage(ChatRole.User, "Test message") }; + object messagesToSend = collectionType == typeof(List) ? sourceMessages.ToList() : sourceMessages; + + await executor.ExecuteAsync(messagesToSend, new TypeId(collectionType), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(1); + executor.ReceivedMessages[0].Text.Should().Be("Test message"); + } + + [Fact] + public async Task ChatProtocolExecutor_MultipleTurns_EachTurnProcessesSeparatelyAsync() + { + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + await executor.ExecuteAsync(new List { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(1); + + await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Turn 2"), new TypeId(typeof(ChatMessage)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().HaveCount(2); + executor.ReceivedMessages[0].Text.Should().Be("Turn 1"); + executor.ReceivedMessages[1].Text.Should().Be("Turn 2"); + executor.TurnCount.Should().Be(2); + } + + [Fact] + public async Task ChatProtocolExecutor_InitialWorkflowMessages_RoutedCorrectlyAsync() + { + var executor = new TestChatProtocolExecutor(); + var context = new TestWorkflowContext(executor.Id); + + List initialMessages = [new ChatMessage(ChatRole.User, "Kick off the workflow")]; + + await executor.ExecuteAsync(initialMessages, new TypeId(typeof(List)), context); + await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context); + + executor.ReceivedMessages.Should().NotBeEmpty(); + executor.ReceivedMessages.Should().HaveCount(1); + executor.ReceivedMessages[0].Text.Should().Be("Kick off the workflow"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs index 747461323d0..78fae79c87b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ExecutionExtensions.cs @@ -1,20 +1,20 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using Microsoft.Agents.AI.Workflows.InProc; namespace Microsoft.Agents.AI.Workflows.UnitTests; internal static class ExecutionExtensions { - public static InProcessExecutionEnvironment GetEnvironment(this ExecutionMode executionMode) + public static IWorkflowExecutionEnvironment ToWorkflowExecutionEnvironment(this ExecutionEnvironment environment) { - return executionMode switch + return environment switch { - ExecutionMode.OffThread => InProcessExecution.OffThread, - ExecutionMode.Lockstep => InProcessExecution.Lockstep, - ExecutionMode.Subworkflow => throw new NotSupportedException(), - _ => throw new InvalidOperationException($"Unknown execution mode {executionMode}") + ExecutionEnvironment.InProcess_OffThread => InProcessExecution.OffThread, + ExecutionEnvironment.InProcess_Lockstep => InProcessExecution.Lockstep, + ExecutionEnvironment.InProcess_Concurrent => InProcessExecution.Concurrent, + + _ => throw new InvalidOperationException($"Unknown execution environment {environment}") }; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs new file mode 100644 index 00000000000..ac4f8a5d95a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessExecutionTests.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Tests for InProcessExecution to verify streaming and non-streaming execution behavior. +/// +public class InProcessExecutionTests +{ + /// + /// The non-streaming version (RunAsync) should execute the workflow and produce events, + /// similar to the streaming version (StreamAsync + TrySendMessageAsync). + /// + [Fact] + public async Task RunAsyncShouldExecuteWorkflowAsync() + { + // Arrange: Create a simple agent that responds to messages + var agent = new SimpleTestAgent("test-agent"); + var workflow = AgentWorkflowBuilder.BuildSequential(agent); + var inputMessage = new ChatMessage(ChatRole.User, "Hello"); + + // Act: Execute using non-streaming RunAsync + Run run = await InProcessExecution.RunAsync(workflow, new List { inputMessage }); + + // Assert: The workflow should have executed and produced events + RunStatus status = await run.GetStatusAsync(); + status.Should().Be(RunStatus.Idle, "workflow should complete execution"); + + // The run should have events (at minimum, a WorkflowOutputEvent) + run.OutgoingEvents.Should().NotBeEmpty("workflow should produce events during execution"); + + // Check that we have an agent execution event + var agentEvents = run.OutgoingEvents.OfType().ToList(); + agentEvents.Should().NotBeEmpty("agent should have executed and produced update events"); + + // Check that we have output events + var outputEvents = run.OutgoingEvents.OfType().ToList(); + outputEvents.Should().NotBeEmpty("workflow should produce output events"); + } + + /// + /// This test shows that the streaming version works correctly when TurnToken is sent following a message. + /// + [Fact] + public async Task StreamAsyncWithTurnTokenShouldExecuteWorkflowAsync() + { + // Arrange: Create a simple agent that responds to messages + var agent = new SimpleTestAgent("test-agent"); + var workflow = AgentWorkflowBuilder.BuildSequential(agent); + var inputMessage = new ChatMessage(ChatRole.User, "Hello"); + + // Act: Execute using streaming version with TurnToken + await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new List { inputMessage }); + + // Send TurnToken to actually trigger execution (this is the key step) + bool messageSent = await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + messageSent.Should().BeTrue("TurnToken should be accepted"); + + // Collect events + List events = new(); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert: The workflow should have executed and produced events + RunStatus status = await run.GetStatusAsync(); + status.Should().Be(RunStatus.Idle, "workflow should complete execution"); + + events.Should().NotBeEmpty("workflow should produce events during execution"); + + // Check that we have agent execution events + var agentEvents = events.OfType().ToList(); + agentEvents.Should().NotBeEmpty("agent should have executed and produced update events"); + + // Check that we have output events + var outputEvents = events.OfType().ToList(); + outputEvents.Should().NotBeEmpty("workflow should produce output events"); + } + + /// + /// This test compares the behavior of RunAsync vs StreamAsync to highlight the difference. + /// Both should produce similar results, but as of issue #1315, RunAsync fails to execute. + /// + [Fact] + public async Task RunAsyncAndStreamAsyncShouldProduceSimilarResultsAsync() + { + // Arrange: Create the same workflow for both tests + var agent1 = new SimpleTestAgent("test-agent-1"); + var workflow1 = AgentWorkflowBuilder.BuildSequential(agent1); + + var agent2 = new SimpleTestAgent("test-agent-2"); + var workflow2 = AgentWorkflowBuilder.BuildSequential(agent2); + + var inputMessage = new ChatMessage(ChatRole.User, "Test message"); + + // Act 1: Execute using RunAsync (non-streaming) + Run nonStreamingRun = await InProcessExecution.RunAsync(workflow1, new List { inputMessage }); + var nonStreamingEvents = nonStreamingRun.OutgoingEvents.ToList(); + + // Act 2: Execute using StreamAsync (streaming) with TurnToken + await using StreamingRun streamingRun = await InProcessExecution.StreamAsync(workflow2, new List { inputMessage }); + await streamingRun.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + List streamingEvents = new(); + await foreach (WorkflowEvent evt in streamingRun.WatchStreamAsync()) + { + streamingEvents.Add(evt); + } + + // Assert: Both should have produced events + // The streaming version works (we know this from the issue report) + streamingEvents.Should().NotBeEmpty("streaming version should produce events"); + + // The non-streaming version should also produce events (this is the bug being tested) + nonStreamingEvents.Should().NotBeEmpty("non-streaming version should also produce events"); + + // Both should have similar types of events + var streamingAgentEvents = streamingEvents.OfType().Count(); + var nonStreamingAgentEvents = nonStreamingEvents.OfType().Count(); + + nonStreamingAgentEvents.Should().Be(streamingAgentEvents, + "both versions should produce the same number of agent events"); + } + + /// + /// Simple test agent that echoes back the input message. + /// + private sealed class SimpleTestAgent : AIAgent + { + private readonly string _name; + + public SimpleTestAgent(string name) + { + this._name = name; + } + + public override string Name => this._name; + + public override AgentThread GetNewThread() => new SimpleTestAgentThread(); + + public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread, + System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) => new SimpleTestAgentThread(); + + public override Task RunAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var lastMessage = messages.LastOrDefault(); + var responseMessage = new ChatMessage(ChatRole.Assistant, $"Echo: {lastMessage?.Text ?? "no message"}"); + return Task.FromResult(new AgentRunResponse(responseMessage)); + } + + public override async IAsyncEnumerable RunStreamingAsync( + IEnumerable messages, + AgentThread? thread = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var lastMessage = messages.LastOrDefault(); + var responseText = $"Echo: {lastMessage?.Text ?? "no message"}"; + + string messageId = Guid.NewGuid().ToString("N"); + + // Yield role first + yield return new AgentRunResponseUpdate(ChatRole.Assistant, this._name) + { + AuthorName = this._name, + MessageId = messageId + }; + + // Then yield content + yield return new AgentRunResponseUpdate(ChatRole.Assistant, responseText) + { + AuthorName = this._name, + MessageId = messageId + }; + } + } + + /// + /// Simple thread implementation for SimpleTestAgent. + /// + private sealed class SimpleTestAgentThread : InMemoryAgentThread + { + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs index debde5fc95e..014c51b3c04 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/InProcessStateTests.cs @@ -43,12 +43,12 @@ Func> Creat return async (turn, context, cancellation) => { - TState? state = await context.ReadStateAsync(stateKey.Key, stateKey.ScopeId.ScopeName) + TState? state = await context.ReadStateAsync(stateKey.Key, stateKey.ScopeId.ScopeName, cancellation) .ConfigureAwait(false); state = action(state); - await context.QueueStateUpdateAsync(stateKey.Key, state, stateKey.ScopeId.ScopeName); + await context.QueueStateUpdateAsync(stateKey.Key, state, stateKey.ScopeId.ScopeName, cancellation); return turn.Next; }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs index 7447f461281..cd0f910ddb0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/JsonSerializationTests.cs @@ -33,7 +33,7 @@ private static JsonSerializerOptions TestCustomSerializedJsonOptions private static EdgeId TakeEdgeId() => new(Interlocked.Increment(ref s_nextEdgeId)); - private static T RunJsonRoundtrip(T value, JsonSerializerOptions? externalOptions = null, Expression>? predicate = null) + internal static T RunJsonRoundtrip(T value, JsonSerializerOptions? externalOptions = null, Expression>? predicate = null) { JsonMarshaller marshaller = new(externalOptions); @@ -172,7 +172,7 @@ private static ValueTask> CreateTestWorkflowAsync() return builder.BuildAsync(); } - private static async ValueTask CreateTestWorkflowInfoAsync() + internal static async ValueTask CreateTestWorkflowInfoAsync() { Workflow testWorkflow = await CreateTestWorkflowAsync().ConfigureAwait(false); return testWorkflow.ToWorkflowInfo(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj index 22fd534b816..bd9bc579159 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj @@ -1,7 +1,8 @@ - + $(ProjectsTargetFrameworks) + $(NoWarn);MEAI001 diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/PortableValueTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/PortableValueTests.cs new file mode 100644 index 00000000000..86ffed0ab42 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/PortableValueTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class PortableValueTests +{ + [SuppressMessage("Performance", "CA1812", Justification = "This is used as a Never/Bottom type.")] + private sealed class Never + { + private Never() { } + } + + [Theory] + [InlineData("string")] + [InlineData(42)] + [InlineData(true)] + [InlineData(3.14)] + public async Task Test_PortableValueRoundtripAsync(T value) + { + value.Should().NotBeNull(); + + PortableValue portableValue = new(value); + + portableValue.Is(out _).Should().BeFalse(); + portableValue.Is(out T? returnedValue).Should().BeTrue(); + returnedValue.Should().Be(value); + } + + [Fact] + public async Task Test_PortableValueRoundtripObjectAsync() + { + ChatMessage value = new(ChatRole.User, "Hello?"); + + PortableValue portableValue = new(value); + + portableValue.Is(out _).Should().BeFalse(); + portableValue.Is(out ChatMessage? returnedValue).Should().BeTrue(); + returnedValue.Should().Be(value); + } + + [Theory] + [InlineData("string")] + [InlineData(42)] + [InlineData(true)] + [InlineData(3.14)] + public async Task Test_DelayedSerializationRoundtripAsync(T value) + { + value.Should().NotBeNull(); + + TestDelayedDeserialization delayed = new(value); + PortableValue portableValue = new(delayed); + + portableValue.Is(out _).Should().BeFalse(); + portableValue.Is(out object? obj).Should().BeTrue(); + obj.Should().NotBeOfType(); + obj.Should().BeOfType() + .And.Subject.As() + .As().Should().Be(value); + + portableValue.Is(out T? returnedValue).Should().BeTrue(); + returnedValue.Should().Be(value); + } + + [Fact] + public async Task Test_DelayedSerializationRoundtripObjectAsync() + { + ChatMessage value = new(ChatRole.User, "Hello?"); + + TestDelayedDeserialization delayed = new(value); + PortableValue portableValue = new(delayed); + + portableValue.Is(out _).Should().BeFalse(); + portableValue.Is(out object? obj).Should().BeTrue(); + obj.Should().NotBeOfType(); + obj.Should().BeOfType() + .And.Subject.As() + .As().Should().Be(value); + + portableValue.Is(out ChatMessage? returnedValue).Should().BeTrue(); + returnedValue.Should().Be(value); + } + + private sealed class TestDelayedDeserialization : IDelayedDeserialization + { + [NotNull] + public T Value { get; } + + public TestDelayedDeserialization([DisallowNull] T value) + { + this.Value = value; + } + + public TValue Deserialize() + { + if (typeof(TValue) == typeof(object)) + { + return (TValue)(object)new PortableValue(this.Value); + } + + if (this.Value is TValue value) + { + return value; + } + + throw new InvalidOperationException(); + } + + public object? Deserialize(Type targetType) + { + if (targetType == typeof(object)) + { + return new PortableValue(this.Value); + } + + if (targetType.IsInstanceOfType(this.Value)) + { + return this.Value; + } + + return null; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs index dd8470b11c9..5027028387d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Reflection; @@ -21,7 +22,7 @@ public bool InvokedHandler public class DefaultHandler() : BaseTestExecutor(nameof(DefaultHandler)), IMessageHandler { - public ValueTask HandleAsync(object message, IWorkflowContext context) + public ValueTask HandleAsync(object message, IWorkflowContext context, CancellationToken cancellationToken = default) { this.OnInvokedHandler(); return this.Handler(message, context); @@ -36,7 +37,7 @@ public Func Handler public class TypedHandler() : BaseTestExecutor>(nameof(TypedHandler)), IMessageHandler { - public ValueTask HandleAsync(TInput message, IWorkflowContext context) + public ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) { this.OnInvokedHandler(); return this.Handler(message, context); @@ -51,7 +52,7 @@ public Func Handler public class TypedHandlerWithOutput() : BaseTestExecutor>(nameof(TypedHandlerWithOutput)), IMessageHandler { - public ValueTask HandleAsync(TInput message, IWorkflowContext context) + public ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) { this.OnInvokedHandler(); return this.Handler(message, context); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index 2e89dabd9e5..72dfca59e42 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -3,10 +3,9 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -26,11 +25,9 @@ public static Workflow WorkflowInstance } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - - StreamingRun run = await env.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); + StreamingRun run = await environment.StreamAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { @@ -42,19 +39,19 @@ public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executio } } -internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor"), IMessageHandler +internal sealed class UppercaseExecutor() : ReflectingExecutor("UppercaseExecutor", declareCrossRunShareable: true), IMessageHandler { - public async ValueTask HandleAsync(string message, IWorkflowContext context) => + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => message.ToUpperInvariant(); } -internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor"), IMessageHandler +internal sealed class ReverseTextExecutor() : ReflectingExecutor("ReverseTextExecutor", declareCrossRunShareable: true), IMessageHandler { - public async ValueTask HandleAsync(string message, IWorkflowContext context) + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { string result = string.Concat(message.Reverse()); - await context.YieldOutputAsync(result).ConfigureAwait(false); + await context.YieldOutputAsync(result, cancellationToken).ConfigureAwait(false); return result; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs index 04757b9350d..ffa798126ea 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01a_Simple_Workflow_Sequential.cs @@ -2,18 +2,15 @@ using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; using static Microsoft.Agents.AI.Workflows.Sample.Step1EntryPoint; namespace Microsoft.Agents.AI.Workflows.Sample; internal static class Step1aEntryPoint { - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run run = await env.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); + Run run = await environment.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false); Assert.Equal(RunStatus.Idle, await run.GetStatusAsync()); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index dd02f935944..50e65dc55c8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -3,10 +3,9 @@ using System; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -30,10 +29,9 @@ public static Workflow WorkflowInstance } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode, string input = "This is a spam message.") + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, string input = "This is a spam message.") { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun handle = await env.StreamAsync(WorkflowInstance, input).ConfigureAwait(false); + StreamingRun handle = await environment.StreamAsync(WorkflowInstance, input).ConfigureAwait(false); await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) { switch (evt) @@ -54,17 +52,17 @@ public static async ValueTask RunAsync(TextWriter writer, ExecutionMode } internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords) : - ReflectingExecutor(id), IMessageHandler + ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { - public async ValueTask HandleAsync(string message, IWorkflowContext context) => + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0); } -internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { public const string ActionResult = "Message processed successfully."; - public async ValueTask HandleAsync(bool message, IWorkflowContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (message) { @@ -72,18 +70,18 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) throw new InvalidOperationException("Received a spam message that should not be getting a reply."); } - await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay - await context.YieldOutputAsync(ActionResult) + await context.YieldOutputAsync(ActionResult, cancellationToken) .ConfigureAwait(false); } } -internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor(id), IMessageHandler +internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor(id, declareCrossRunShareable: true), IMessageHandler { public const string ActionResult = "Spam message removed."; - public async ValueTask HandleAsync(bool message, IWorkflowContext context) + public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default) { if (!message) { @@ -91,9 +89,9 @@ public async ValueTask HandleAsync(bool message, IWorkflowContext context) throw new InvalidOperationException("Received a non-spam message that should not be getting removed."); } - await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay - await context.YieldOutputAsync(ActionResult) + await context.YieldOutputAsync(ActionResult, cancellationToken) .ConfigureAwait(false); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index be1d1e1623a..62ba2a8a68c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -4,9 +4,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Reflection; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -27,10 +25,9 @@ public static Workflow WorkflowInstance } } - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun run = await env.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false); + StreamingRun run = await environment.StreamAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false); await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) { @@ -51,6 +48,16 @@ public static async ValueTask RunAsync(TextWriter writer, ExecutionMode } } +internal sealed record TryCount(int Tries); + +internal sealed record NumberBounds(int LowerBound, int UpperBound) +{ + public int CurrGuess => (this.LowerBound + this.UpperBound) / 2; + + public NumberBounds ForAboveHint() => this with { UpperBound = this.CurrGuess - 1 }; + public NumberBounds ForBelowHint() => this with { LowerBound = this.CurrGuess + 1 }; +} + internal enum NumberSignal { Init, @@ -61,74 +68,65 @@ internal enum NumberSignal internal sealed class GuessNumberExecutor : ReflectingExecutor, IMessageHandler { - public int LowerBound { get; private set; } - public int UpperBound { get; private set; } + private readonly int _initialLowerBound; + private readonly int _initialUpperBound; - public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }) + public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }, declareCrossRunShareable: true) { - this.LowerBound = lowerBound; - this.UpperBound = upperBound; - } + if (lowerBound >= upperBound) + { + throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be less than upper bound."); + } - private int NextGuess => (this.LowerBound + this.UpperBound) / 2; + this._initialLowerBound = lowerBound; + this._initialUpperBound = upperBound; + } - private int _currGuess = -1; - public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context) + public async ValueTask HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default) { + NumberBounds bounds = await context.ReadStateAsync(nameof(NumberBounds), cancellationToken: cancellationToken) + .ConfigureAwait(false) + ?? new NumberBounds(this._initialLowerBound, this._initialUpperBound); + switch (message) { case NumberSignal.Matched: - await context.YieldOutputAsync($"Guessed the number: {this._currGuess}") + await context.YieldOutputAsync($"Guessed the number: {bounds.CurrGuess}", cancellationToken) .ConfigureAwait(false); break; case NumberSignal.Above: - this.UpperBound = this._currGuess - 1; + bounds = bounds.ForAboveHint(); break; case NumberSignal.Below: - this.LowerBound = this._currGuess + 1; + bounds = bounds.ForBelowHint(); break; } - this._currGuess = this.NextGuess; - return this._currGuess; + await context.QueueStateUpdateAsync(nameof(NumberBounds), bounds, cancellationToken: cancellationToken).ConfigureAwait(false); + + return bounds.CurrGuess; } } -internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler, IResettableExecutor +internal sealed class JudgeExecutor : ReflectingExecutor, IMessageHandler { private readonly int _targetNumber; - internal int? Tries { get; private set; } - - public JudgeExecutor(string id, int targetNumber) : base(id) + public JudgeExecutor(string id, int targetNumber) : base(id, declareCrossRunShareable: true) { this._targetNumber = targetNumber; } - public async ValueTask HandleAsync(int message, IWorkflowContext context) + public async ValueTask HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default) { - this.Tries = this.Tries is int tries ? tries + 1 : 1; + // This works properly because the default when unset is 0, and we increment before use. + int tries = await context.ReadStateAsync("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) + 1; + await context.YieldOutputAsync(new TryCount(tries), cancellationToken); return message == this._targetNumber ? NumberSignal.Matched : message < this._targetNumber ? NumberSignal.Below : NumberSignal.Above; } - - protected internal override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - return context.QueueStateUpdateAsync("TryCount", this.Tries); - } - - protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) - { - this.Tries = await context.ReadStateAsync("TryCount").ConfigureAwait(false) ?? 0; - } - - public ValueTask ResetAsync() - { - this.Tries = null; - return default; - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs index e297e9da919..69d21600c4d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/04_Simple_Workflow_ExternalRequest.cs @@ -4,8 +4,6 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -39,14 +37,13 @@ public static Workflow WorkflowInstance } } - public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, ExecutionMode executionMode) + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, IWorkflowExecutionEnvironment environment) { NumberSignal signal = NumberSignal.Init; string? prompt = UpdatePrompt(null, signal); Workflow workflow = WorkflowInstance; - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun handle = await env.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); + StreamingRun handle = await environment.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false); List requests = []; await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false)) @@ -57,13 +54,15 @@ public static async ValueTask RunAsync(TextWriter writer, Func()) + if (outputEvent.Is(out NumberSignal newSignal)) + { + prompt = UpdatePrompt(prompt, signal = newSignal); + } + else if (!outputEvent.Is()) { throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); } - signal = outputEvent.As()!.Value; - prompt = UpdatePrompt(prompt, signal); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index 4e6a137cdcb..aab5fd09581 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -6,14 +6,12 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; internal static class Step5EntryPoint { - public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, ExecutionMode executionMode, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null) + public static async ValueTask RunAsync(TextWriter writer, Func userGuessCallback, IWorkflowExecutionEnvironment environment, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null) { Dictionary checkpointedOutputs = []; @@ -24,10 +22,9 @@ public static async ValueTask RunAsync(TextWriter writer, Func checkpointed = - await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) - .ConfigureAwait(false); + await environment.StreamAsync(workflow, NumberSignal.Init, checkpointManager) + .ConfigureAwait(false); List checkpoints = []; CancellationTokenSource cancellationSource = new(); @@ -37,7 +34,6 @@ await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) result.Should().BeNull(); checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step"); - judge.Tries.Should().Be(2); CheckpointInfo targetCheckpoint = checkpoints[2]; @@ -46,8 +42,8 @@ await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) { await handle.DisposeAsync().ConfigureAwait(false); - checkpointed = await env.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None) - .ConfigureAwait(false); + checkpointed = await environment.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellationToken: CancellationToken.None) + .ConfigureAwait(false); handle = checkpointed.Run; } else @@ -57,8 +53,6 @@ await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) (signal, prompt) = checkpointedOutputs[targetCheckpoint]; - judge.Tries.Should().Be(1); - cancellationSource.Dispose(); cancellationSource = new(); @@ -66,7 +60,11 @@ await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false); result.Should().NotBeNull(); - checkpoints.Should().HaveCount(6); + + // Depending on the timing of the response with respect to the underlying workflow + // we may end up with an extra superstep in between. + checkpoints.Should().HaveCountGreaterThanOrEqualTo(6) + .And.HaveCountLessThanOrEqualTo(7); cancellationSource.Dispose(); @@ -84,13 +82,16 @@ await env.StreamAsync(workflow, NumberSignal.Init, checkpointManager) switch (outputEvent.SourceId) { case Step4EntryPoint.JudgeId: - if (!outputEvent.Is()) + if (outputEvent.Is(out NumberSignal newSignal)) + { + prompt = Step4EntryPoint.UpdatePrompt(prompt, signal = newSignal); + } + // TODO: We should make some well-defined way to avoid this kind of + // if/elseif chain, because .Is() chains are slow + else if (!outputEvent.Is()) { throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}"); } - - signal = outputEvent.As()!.Value; - prompt = Step4EntryPoint.UpdatePrompt(null, signal); break; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs index 3e6049f4ce5..33a0fbc13f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/06_GroupChat_Workflow.cs @@ -10,8 +10,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -20,16 +18,15 @@ internal static class Step6EntryPoint { public static Workflow CreateWorkflow(int maxTurns) => AgentWorkflowBuilder - .CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) + .CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns }) .AddParticipants(new HelloAgent(), new EchoAgent()) .Build(); - public static async ValueTask RunAsync(TextWriter writer, ExecutionMode executionMode, int maxSteps = 2) + public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2) { Workflow workflow = CreateWorkflow(maxSteps); - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - StreamingRun run = await env.StreamAsync(workflow, Array.Empty()) + StreamingRun run = await environment.StreamAsync(workflow, Array.Empty()) .ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs index d865a6275c7..8ec7978606c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/08_Subworkflow_Simple.cs @@ -7,15 +7,13 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; internal sealed record class TextProcessingRequest(string Text, string TaskId); internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount); -internal sealed class AllTasksCompletedEvent(IEnumerable results) : WorkflowEvent(results); +//internal sealed class AllTasksCompletedEvent(IEnumerable results) : WorkflowEvent(results); internal static class Step8EntryPoint { @@ -28,35 +26,43 @@ internal static class Step8EntryPoint " Spaces around text ", ]; - public static async ValueTask> RunAsync(TextWriter writer, ExecutionMode executionMode, List textsToProcess) + public static async ValueTask> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List textsToProcess) { Func processTextAsyncFunc = ProcessTextAsync; - ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor"); + ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true); Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build(); ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor"); - TextProcessingOrchestrator orchestrator = new(); + Func> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id)); + var orchestrator = createOrchestrator.ConfigureFactory(); Workflow workflow = new WorkflowBuilder(orchestrator) .AddEdge(orchestrator, textProcessor) .AddEdge(textProcessor, orchestrator) + .WithOutputFrom(orchestrator) .Build(); - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run workflowRun = await env.RunAsync(workflow, textsToProcess); + Run workflowRun = await environment.RunAsync(workflow, textsToProcess); RunStatus status = await workflowRun.GetStatusAsync(); status.Should().Be(RunStatus.Idle); - List results = orchestrator.Results; + WorkflowOutputEvent? maybeOutput = workflowRun.OutgoingEvents.OfType() + .SingleOrDefault(); + + maybeOutput.Should().NotBeNull("the workflow should have produced an output event"); + List? maybeResults = maybeOutput.As>(); + + maybeResults.Should().NotBeNull("the output event should contain the results"); + List results = maybeResults; + results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId)); - // This is a placeholder for the entry point of Step 8. return results; } - private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellation = default) + private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { int wordCount = 0; int charCount = 0; @@ -67,13 +73,22 @@ private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkfl charCount = request.Text.Length; } - return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount)); + return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken); } - private sealed class TextProcessingOrchestrator() : Executor("TextOrchestrator") + private sealed class TextProcessingOrchestrator(string id) + : StatefulExecutor(id, () => new(), declareCrossRunShareable: false) { - public List Results { get; } = new(); - public HashSet PendingTaskIds { get; } = new(); + internal sealed class State + { + public List Results { get; } = new(); + public HashSet PendingTaskIds { get; } = new(); + + public bool IsComplete => this.PendingTaskIds.Count == 0; + + public void AddPending(string taskId) => this.PendingTaskIds.Add(taskId); + public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId); + } protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -81,28 +96,40 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) .AddHandler(this.CollectResultAsync); } - private async ValueTask StartProcessingAsync(List texts, IWorkflowContext context) + private async ValueTask StartProcessingAsync(List texts, IWorkflowContext context, CancellationToken cancellationToken) { - foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) + await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken); + + async ValueTask QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken) { - this.PendingTaskIds.Add(request.TaskId); - await context.SendMessageAsync(request).ConfigureAwait(false); + foreach (TextProcessingRequest request in texts.Select((string value, int index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}"))) + { + state.PendingTaskIds.Add(request.TaskId); + await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + return state; } } - private ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context) + private async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this.PendingTaskIds.Remove(result.TaskId)) - { - this.Results.Add(result); - } + await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken); - if (this.PendingTaskIds.Count == 0) + async ValueTask CollectResultAndCheckCompletionAsync(State state, IWorkflowContext context, CancellationToken cancellationToken) { - return context.AddEventAsync(new AllTasksCompletedEvent(this.Results)); - } + if (state.PendingTaskIds.Remove(result.TaskId)) + { + state.Results.Add(result); + } + + if (state.PendingTaskIds.Count == 0) + { + await context.YieldOutputAsync(state.Results, cancellationToken).ConfigureAwait(false); + } - return default; + return state; + } } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs index 083221ab2a3..7f9b6189bc8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/09_Subworkflow_ExternalRequest.cs @@ -7,8 +7,6 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.UnitTests; namespace Microsoft.Agents.AI.Workflows.Sample; @@ -16,13 +14,26 @@ internal sealed record class UserRequest(string RequestType, string Type, int Am { internal static int RequestCount; - public static string CreateId() => Interlocked.Increment(ref RequestCount).ToString(); + public static string CreateId() + { + string result = Interlocked.Increment(ref RequestCount).ToString(); + Console.Error.WriteLine($"Got Id: {result}"); + return result; + } - public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") => - new("resource", resourceType, amount, Priority: priority, Id: CreateId()); + public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal") + { + UserRequest request = new("resource", resourceType, amount, Priority: priority, Id: CreateId()); + Console.Error.WriteLine($"\t{request}"); + return request; + } - public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") => - new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId()); + public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota") + { + UserRequest request = new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId()); + Console.Error.WriteLine($"\t{request}"); + return request; + } public ResourceResponse CreateResourceResponse(int allocated, string source) => new(this.Id, this.Type, allocated, source); @@ -155,7 +166,7 @@ public static Workflow CreateWorkflow() public static UserRequest[] RequestsToProcess => [ ResourceHitRequest1, PolicyHitRequest1, - ResourceHitRequest1, + ResourceHitRequest2, PolicyMissRequest1, // miss ResourceMissRequest, // miss PolicyHitRequest2, @@ -172,13 +183,12 @@ [.. RequestsToProcess.Where(request => Part2FinishedResponses.ContainsKey(reques .Select(request => Part2FinishedResponses[request.Id]) .OrderBy(request => request.Id)]; - public static async ValueTask> RunAsync(TextWriter writer, ExecutionMode executionMode) + public static async ValueTask> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment) { RunStatus runStatus; List results = []; - InProcessExecutionEnvironment env = executionMode.GetEnvironment(); - Run workflowRun = await env.RunAsync(WorkflowInstance, RequestsToProcess.ToList()); + Run workflowRun = await environment.RunAsync(WorkflowInstance, RequestsToProcess.ToList()); RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle; runStatus = await workflowRun.GetStatusAsync(); @@ -205,6 +215,11 @@ public static async ValueTask> RunAsync(TextWriter writer, policyRequests.Add(requestInfoEvent.Request); } } + else if (evt is WorkflowErrorEvent error) + { + Assert.Fail(((Exception)error.Data!).ToString()); + Console.Error.WriteLine(error.Data); + } } finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id)); @@ -260,7 +275,7 @@ public static async ValueTask> RunAsync(TextWriter writer, } } -internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor)) +internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true) { protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -304,17 +319,18 @@ private async ValueTask HandleResponseAsync(PolicyResponse response, IWorkflowCo await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response)); } } - -internal sealed class ResourceCache() : Executor(nameof(ResourceCache)) +internal sealed class ResourceCache() + : StatefulExecutor>(nameof(ResourceCache), + InitializeResourceCache, + declareCrossRunShareable: true) { - private readonly Dictionary _availableResources = new() - { - ["cpu"] = 10, - ["memory"] = 50, - ["disk"] = 100, - }; - - internal List Responses { get; } = []; + private static Dictionary InitializeResourceCache() + => new() + { + ["cpu"] = 10, + ["memory"] = 50, + ["disk"] = 100, + }; protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -324,45 +340,60 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) .AddHandler(this.CollectResultAsync); } - private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context) + private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { if (request.DataIs(out ResourceRequest? resourceRequest)) { - ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context) + ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context, cancellationToken) .ConfigureAwait(false); if (response != null) { - await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false); + await context.SendMessageAsync(request.CreateResponse(response), cancellationToken: cancellationToken).ConfigureAwait(false); } else { // Cache does not have enough resources, forward the request to the external system - await context.SendMessageAsync(request).ConfigureAwait(false); + await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false); } } } - private async ValueTask TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context) + private async ValueTask TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (this._availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount) + Console.Error.WriteLine($"Handling Resource Request {request.Id}"); + + Dictionary availableResources = await this.ReadStateAsync(context, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + Console.Error.WriteLine($"Available Resources: {availableResources}"); + + try + { + if (availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount) + { + // Cache has enough resources, allocate from cache + availableResources[request.ResourceType] -= request.Amount; + + Console.Error.WriteLine($"Handled Resource Request {request.Id}"); + return new(request.Id, request.ResourceType, request.Amount, Source: "cache"); + } + } + finally { - // Cache has enough resources, allocate from cache - this._availableResources[request.ResourceType] -= request.Amount; - ResourceResponse resourceResponse = new(request.Id, request.ResourceType, request.Amount, Source: "cache"); - this.Responses.Add(resourceResponse); - return resourceResponse; + await this.QueueStateUpdateAsync(availableResources, context, cancellationToken) + .ConfigureAwait(false); } + Console.Error.WriteLine($"Could not handle Resource Request {request.Id}"); return null; } private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context) { - if (response.DataIs(out ResourceResponse? resourceResponse)) + if (response.DataIs()) { // Normally we'd update the cache according to whatever logic we want here. - this.Responses.Add(resourceResponse); return context.SendMessageAsync(response); } @@ -370,16 +401,18 @@ private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext } } -internal sealed class QuotaPolicyEngine() : Executor(nameof(QuotaPolicyEngine)) +internal sealed class QuotaPolicyEngine() + : StatefulExecutor>(nameof(QuotaPolicyEngine), + InitializePolicyQuotas, + declareCrossRunShareable: true) { - private readonly Dictionary _quotas = new() - { - ["cpu"] = 5, - ["memory"] = 20, - ["disk"] = 1000, - }; - - internal List Responses { get; } = []; + private static Dictionary InitializePolicyQuotas() + => new() + { + ["cpu"] = 5, + ["memory"] = 20, + ["disk"] = 1000, + }; protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -406,25 +439,39 @@ private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWo } } - private async ValueTask TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context) + private async ValueTask TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context, CancellationToken cancellationToken = default) { - if (request.PolicyType == "quota" && - this._quotas.TryGetValue(request.ResourceType, out int quota) && - request.Amount <= quota) + Console.Error.WriteLine($"Handling Policy Request {request.Id}"); + + Dictionary quotas = await this.ReadStateAsync(context, cancellationToken: cancellationToken) + .ConfigureAwait(false); + + Console.Error.WriteLine($"Policy Quotas: {quotas}"); + + try { - PolicyResponse policyResponse = new(request.Id, Approved: true, Reason: $"Within quota ({quota})"); - this.Responses.Add(policyResponse); + if (request.PolicyType == "quota" && + quotas.TryGetValue(request.ResourceType, out int quota) && + request.Amount <= quota) + { + Console.Error.WriteLine($"Handled Policy Request {request.Id}"); - return policyResponse; - } + return new(request.Id, Approved: true, Reason: $"Within quota ({quota})"); + } - return null; + Console.Error.WriteLine($"Could not handle Policy Request {request.Id}"); + + return null; + } + finally + { + await this.QueueStateUpdateAsync(quotas, context, cancellationToken).ConfigureAwait(false); + } } private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context) { - if (response.DataIs(out PolicyResponse? policyResponse)) + if (response.DataIs()) { - this.Responses.Add(policyResponse); return context.SendMessageAsync(response); } @@ -432,11 +479,9 @@ private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowCon } } -internal sealed class Coordinator() : Executor(nameof(Coordinator)) +internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCrossRunShareable: true) { - private int _inflightRequests; - - internal List Results { get; } = []; + private const string StateKey = nameof(StateKey); protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) { @@ -447,24 +492,34 @@ protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) // For some reason, using a lambda here causes the analyzer to generate a spurious // VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning // to a variable, or passing it to another method" - ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context) - => this.StartAsync([request], context); + ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context, CancellationToken cancellationToken) + => this.StartAsync([request], context, cancellationToken); } - private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context) + private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context, CancellationToken cancellationToken) { - this.Results.Add(finished); - Interlocked.Decrement(ref this._inflightRequests); + return context.InvokeWithStateAsync(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken); + + async ValueTask CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken) + { + await context.YieldOutputAsync(finished, cancellationToken).ConfigureAwait(false); - return context.YieldOutputAsync(finished); + return state - 1; + } } - private async ValueTask StartAsync(List request, IWorkflowContext context) + private ValueTask StartAsync(List requests, IWorkflowContext context, CancellationToken cancellationToken) { - Interlocked.Add(ref this._inflightRequests, request.Count); - foreach (UserRequest req in request) + return context.InvokeWithStateAsync(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken); + + async ValueTask CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken) { - await context.SendMessageAsync(req).ConfigureAwait(false); + foreach (UserRequest req in requests) + { + await context.SendMessageAsync(req, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + return state + requests.Count; } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index 378475a8560..7730063ec23 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -11,16 +11,24 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; +internal enum ExecutionEnvironment +{ + InProcess_Lockstep, + InProcess_OffThread, + InProcess_Concurrent +} + public class SampleSmokeTest { [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step1Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step1Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step1EntryPoint.RunAsync(writer, executionMode); + await Step1EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -34,13 +42,14 @@ internal async Task Test_RunSample_Step1Async(ExecutionMode executionMode) } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step1aAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step1aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step1aEntryPoint.RunAsync(writer, executionMode); + await Step1aEntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -54,37 +63,40 @@ internal async Task Test_RunSample_Step1aAsync(ExecutionMode executionMode) } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step2Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step2Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - string spamResult = await Step2EntryPoint.RunAsync(writer, executionMode); + string spamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); Assert.Equal(RemoveSpamExecutor.ActionResult, spamResult); - string nonSpamResult = await Step2EntryPoint.RunAsync(writer, executionMode, "This is a valid message."); + string nonSpamResult = await Step2EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), "This is a valid message."); Assert.Equal(RespondToMessageExecutor.ActionResult, nonSpamResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step3Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step3Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - string guessResult = await Step3EntryPoint.RunAsync(writer, executionMode); + string guessResult = await Step3EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("Guessed the number: 42", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step4Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step4Async(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -93,14 +105,15 @@ internal async Task Test_RunSample_Step4Async(ExecutionMode executionMode) ("Your guess was too high. Try again.", 23), ("Your guess was too low. Try again.", 42)); - string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode); + string guessResult = await Step4EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5Async(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -114,14 +127,15 @@ internal async Task Test_RunSample_Step5Async(ExecutionMode executionMode) ("Your guess was too low. Try again.", 42) ); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment()); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5aAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5aAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -135,14 +149,15 @@ internal async Task Test_RunSample_Step5aAsync(ExecutionMode executionMode) ("Your guess was too low. Try again.", 42) ); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step5bAsync(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step5bAsync(ExecutionEnvironment environment) { using StringWriter writer = new(); @@ -160,18 +175,19 @@ internal async Task Test_RunSample_Step5bAsync(ExecutionMode executionMode) options.MakeReadOnly(); CheckpointManager memoryJsonManager = CheckpointManager.CreateJson(new InMemoryJsonStore(), options); - string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, executionMode, rehydrateToRestore: true, checkpointManager: memoryJsonManager); + string guessResult = await Step5EntryPoint.RunAsync(writer, userGuessCallback: responder.InvokeNext, environment.ToWorkflowExecutionEnvironment(), rehydrateToRestore: true, checkpointManager: memoryJsonManager); Assert.Equal("You guessed correctly! You Win!", guessResult); } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step6Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step6Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - await Step6EntryPoint.RunAsync(writer, executionMode); + await Step6EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); string result = writer.ToString(); string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries); @@ -199,9 +215,10 @@ public async Task Test_RunSample_Step7Async() } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step8Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step8Async(ExecutionEnvironment environment) { List textsToProcess = [ "Hello world! This is a simple test.", @@ -214,7 +231,7 @@ internal async Task Test_RunSample_Step8Async(ExecutionMode executionMode) using StringWriter writer = new(); - List results = await Step8EntryPoint.RunAsync(writer, executionMode, textsToProcess); + List results = await Step8EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), textsToProcess); Assert.Equal(textsToProcess.Count, results.Count); Assert.Collection(results, @@ -237,12 +254,13 @@ Action CreateValidator(string textToProcess, int index) } [Theory] - [InlineData(ExecutionMode.Lockstep)] - [InlineData(ExecutionMode.OffThread)] - internal async Task Test_RunSample_Step9Async(ExecutionMode executionMode) + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Concurrent)] + internal async Task Test_RunSample_Step9Async(ExecutionEnvironment environment) { using StringWriter writer = new(); - _ = await Step9EntryPoint.RunAsync(writer, executionMode); + _ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs index 6b883ebd1e3..87afd18d695 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SpecializedExecutorSmokeTests.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Execution; using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; @@ -111,32 +112,36 @@ public override async IAsyncEnumerable RunStreamingAsync public sealed class TestAgentThread() : InMemoryAgentThread(); - internal sealed class TestWorkflowContext : IWorkflowContext + internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext { + private readonly StateManager _stateManager = new(); + public List> Updates { get; } = []; - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) => + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) => default; - public ValueTask YieldOutputAsync(object output) => + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) => default; public ValueTask RequestHaltAsync() => default; - public ValueTask QueueClearScopeAsync(string? scopeName = null) => - default; + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName)); - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) => - default; + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) + => value is null + ? this._stateManager.ClearStateAsync(new ScopeId(executorId, scopeName), key) + : this._stateManager.WriteStateAsync(new ScopeId(executorId, scopeName), key, value); - public ValueTask ReadStateAsync(string key, string? scopeName = null) => - throw new NotImplementedException(); + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ReadStateAsync(new ScopeId(executorId, scopeName), key); - public ValueTask> ReadStateKeysAsync(string? scopeName = null) => - throw new NotImplementedException(); + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this._stateManager.ReadKeysAsync(new ScopeId(executorId, scopeName)); - public ValueTask SendMessageAsync(object message, string? targetId = null) + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) { if (message is List messages) { @@ -150,7 +155,15 @@ public ValueTask SendMessageAsync(object message, string? targetId = null) return default; } + public async ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + return (await this.ReadStateAsync(key, scopeName, cancellationToken).ConfigureAwait(false)) + ?? initialStateFactory(); + } + public IReadOnlyDictionary? TraceContext => null; + + public bool ConcurrentRunsEnabled => concurrentRunsEnabled; } [Fact] @@ -177,7 +190,7 @@ public async Task Test_AIAgentStreamingMessage_AggregationAsync() TestAIAgent agent = new(expected); AIAgentHostExecutor host = new(agent); - TestWorkflowContext collectingContext = new(); + TestWorkflowContext collectingContext = new(host.Id); await host.TakeTurnAsync(new TurnToken(emitEvents: false), collectingContext); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs index 4bb07469966..fc16fd66000 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/StateManagerTests.cs @@ -4,7 +4,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.UnitTests; @@ -451,4 +453,119 @@ private static async Task RunConflictingUpdatesTest_WriteVsClearAsync(string? sc await act.Should().NotThrowAsync("writes to private scopes should not be visible across executors"); } } + + private static void VerifyIs(PortableValue? candidatePV, TExpectedType value) + { + candidatePV.Should().NotBeNull(); + candidatePV.Is(out TExpectedType? candidateValue).Should().BeTrue(); + candidateValue.Should().Be(value); + } + + private static void VerifyIsNot(PortableValue? candidatePV) + { + candidatePV.Should().NotBeNull(); + candidatePV.Is(out TExpectedType? _).Should().BeFalse(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Test_LoadPortableValueStateAsync(bool publishStateUpdates) + { + ScopeId scope = new("executor1"); + const string StringValue = "string"; + const int IntValue = 42; + ScopeKey ScopeKey = new("executor1", "scope", "key"); + PortableValue PortableValueValue = new(StringValue); + + // Arrange + StateManager manager = new(); + await manager.WriteStateAsync(scope, nameof(StringValue), StringValue); + await manager.WriteStateAsync(scope, nameof(IntValue), IntValue); + await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey); + await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue); + + if (publishStateUpdates) + { + await manager.PublishUpdatesAsync(tracer: null); + } + + // Act & Assert - Read as the original types + PortableValue? stringAsPV = await manager.ReadStateAsync(scope, nameof(StringValue)); + VerifyIs(stringAsPV, StringValue); + VerifyIsNot(stringAsPV); + VerifyIsNot(stringAsPV); + VerifyIsNot(stringAsPV); + + PortableValue? intAsPV = await manager.ReadStateAsync(scope, nameof(IntValue)); + VerifyIsNot(intAsPV); + VerifyIs(intAsPV, IntValue); + VerifyIsNot(intAsPV); + VerifyIsNot(intAsPV); + + PortableValue? scopeKeyAsPV = await manager.ReadStateAsync(scope, nameof(ScopeKey)); + VerifyIsNot(scopeKeyAsPV); + VerifyIsNot(scopeKeyAsPV); + VerifyIs(scopeKeyAsPV, ScopeKey); + VerifyIsNot(scopeKeyAsPV); + + PortableValue? pvAsPV = await manager.ReadStateAsync(scope, nameof(PortableValueValue)); + VerifyIs(pvAsPV, StringValue); + VerifyIsNot(pvAsPV); + VerifyIsNot(pvAsPV); + + // Check that we don't double-wrap stored PortableValues on the out path + VerifyIsNot(pvAsPV); + } + + [Fact] + public async Task Test_LoadPortableValueState_AfterSerializationAsync() + { + ScopeId scope = new("executor1"); + const string StringValue = "string"; + const int IntValue = 42; + ScopeKey ScopeKey = new("executor1", "scope", "key"); + PortableValue PortableValueValue = new(StringValue); + + // Arrange + StateManager manager = new(); + await manager.WriteStateAsync(scope, nameof(StringValue), StringValue); + await manager.WriteStateAsync(scope, nameof(IntValue), IntValue); + await manager.WriteStateAsync(scope, nameof(ScopeKey), ScopeKey); + await manager.WriteStateAsync(scope, nameof(PortableValueValue), PortableValueValue); + + await manager.PublishUpdatesAsync(tracer: null); + + Dictionary exportedState = await manager.ExportStateAsync(); + Dictionary serializedState = JsonSerializationTests.RunJsonRoundtrip(exportedState); + Checkpoint testCheckpoint = new(0, await JsonSerializationTests.CreateTestWorkflowInfoAsync(), new([], [], []), serializedState, new()); + + manager = new(); + await manager.ImportStateAsync(testCheckpoint); + + // Act & Assert - Read as the original types + PortableValue? stringAsPV = await manager.ReadStateAsync(scope, nameof(StringValue)); + VerifyIs(stringAsPV, StringValue); + VerifyIsNot(stringAsPV); + VerifyIsNot(stringAsPV); + + PortableValue? intAsPV = await manager.ReadStateAsync(scope, nameof(IntValue)); + VerifyIsNot(intAsPV); + VerifyIs(intAsPV, IntValue); + VerifyIsNot(intAsPV); + + PortableValue? scopeKeyAsPV = await manager.ReadStateAsync(scope, nameof(ScopeKey)); + VerifyIsNot(scopeKeyAsPV); + VerifyIsNot(scopeKeyAsPV); + VerifyIs(scopeKeyAsPV, ScopeKey); + VerifyIsNot(scopeKeyAsPV); + + PortableValue? pvAsPV = await manager.ReadStateAsync(scope, nameof(PortableValueValue)); + VerifyIs(pvAsPV, StringValue); + VerifyIsNot(pvAsPV); + VerifyIsNot(pvAsPV); + + // Check that we don't double-wrap stored PortableValues on the out path + VerifyIsNot(pvAsPV); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs index c996bdb4e1d..57375b8341a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs @@ -15,36 +15,43 @@ private sealed class BoundContext( TestRunContext runnerContext, IReadOnlyDictionary? traceContext) : IWorkflowContext { - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) - => runnerContext.AddEventAsync(workflowEvent); + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + => runnerContext.AddEventAsync(workflowEvent, cancellationToken); - public ValueTask YieldOutputAsync(object output) - => this.AddEventAsync(new WorkflowOutputEvent(output, executorId)); + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) + => this.AddEventAsync(new WorkflowOutputEvent(output, executorId), cancellationToken); public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent()); - public ValueTask QueueClearScopeAsync(string? scopeName = null) + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) => default; - public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null) + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) => default; - public ValueTask ReadStateAsync(string key, string? scopeName = null) + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) => new(default(T?)); - public ValueTask> ReadStateKeysAsync(string? scopeName = null) + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) => new([]); - public ValueTask SendMessageAsync(object message, string? targetId = null) - => runnerContext.SendMessageAsync(executorId, message, targetId); + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) + => runnerContext.SendMessageAsync(executorId, message, targetId, cancellationToken); + + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + { + return new(initialStateFactory()); + } public IReadOnlyDictionary? TraceContext => traceContext; + + public bool ConcurrentRunsEnabled => runnerContext.ConcurrentRunsEnabled; } public List Events { get; } = []; - public ValueTask AddEventAsync(WorkflowEvent workflowEvent) + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken) { this.Events.Add(workflowEvent); return default; @@ -61,7 +68,7 @@ public ValueTask PostAsync(ExternalRequest request) } internal Dictionary> QueuedMessages { get; } = []; - public ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null) + public ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default) { if (!this.QueuedMessages.TryGetValue(sourceId, out List? deliveryQueue)) { @@ -72,18 +79,19 @@ public ValueTask SendMessageAsync(string sourceId, object message, string? targe return default; } - ValueTask IRunnerContext.AdvanceAsync() => + ValueTask IRunnerContext.AdvanceAsync(CancellationToken cancellationToken) => throw new NotImplementedException(); public Dictionary Executors { get; set; } = []; public string StartingExecutorId { get; set; } = string.Empty; public bool WithCheckpointing => throw new NotSupportedException(); + public bool ConcurrentRunsEnabled => throw new NotSupportedException(); - ValueTask IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer) => + ValueTask IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) => new(this.Executors[executorId]); - public ValueTask> GetStartingExecutorInputTypesAsync(CancellationToken cancellation = default) + public ValueTask> GetStartingExecutorInputTypesAsync(CancellationToken cancellationToken = default) { if (this.Executors.TryGetValue(this.StartingExecutorId, out Executor? executor)) { @@ -93,11 +101,12 @@ public ValueTask> GetStartingExecutorInputTypesAsync(Cancellat throw new InvalidOperationException($"No executor with ID '{this.StartingExecutorId}' is registered in this context."); } - public ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellation = default) - => this.AddEventAsync(workflowEvent); + public ValueTask ForwardWorkflowEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + => this.AddEventAsync(workflowEvent, cancellationToken); - public ValueTask SendMessageAsync(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellation = default) - => this.SendMessageAsync(senderId, message, cancellation); + public ValueTask SendMessageAsync(string senderId, [System.Diagnostics.CodeAnalysis.DisallowNull] TMessage message, CancellationToken cancellationToken = default) + => this.SendMessageAsync(senderId, message, cancellationToken); - ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellation) => default; + ValueTask ISuperStepJoinContext.AttachSuperstepAsync(ISuperStepRunner superStepRunner, CancellationToken cancellationToken) => new(string.Empty); + ValueTask ISuperStepJoinContext.DetachSuperstepAsync(string joinId) => new(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs new file mode 100644 index 00000000000..402544fdb6d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunState.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Threading; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestRunState +{ + public ConcurrentDictionary> SentMessages = new(); + public StateManager StateManager { get; } = new(); + public ConcurrentQueue EmittedEvents { get; } = new(); + public ConcurrentDictionary> YieldedOutputs { get; } = new(); + + private int _haltRequests; + public int HaltRequests + { + get => Volatile.Read(ref this._haltRequests); + } + + public void IncrementHaltRequests() + { + Interlocked.Increment(ref this._haltRequests); + } + + public TestWorkflowContext ContextFor(string executorId) => new(executorId, this); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs new file mode 100644 index 00000000000..61fb4e19706 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestWorkflowContext.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Execution; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal sealed class TestWorkflowContext : IWorkflowContext +{ + private readonly string _executorId; + private readonly TestRunState _state; + + public TestWorkflowContext(string executorId, TestRunState? state = null, bool concurrentRunsEnabled = false) + { + this._executorId = executorId; + this._state = state ?? new TestRunState(); + + this.ConcurrentRunsEnabled = concurrentRunsEnabled; + } + + public bool ConcurrentRunsEnabled { get; } + + public ConcurrentQueue SentMessages => this._state.SentMessages.GetOrAdd(this._executorId, _ => new()); + + public StateManager StateManager => this._state.StateManager; + + public ConcurrentQueue EmittedEvents => this._state.EmittedEvents; + + public ConcurrentQueue YieldedOutputs => this._state.YieldedOutputs.GetOrAdd(this._executorId, _ => new()); + + public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default) + { + this.EmittedEvents.Enqueue(workflowEvent); + return default; + } + + public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default) + { + this.YieldedOutputs.Enqueue(output); + return this.AddEventAsync(new WorkflowOutputEvent(output, this._executorId), cancellationToken); + } + + public ValueTask RequestHaltAsync() + { + this._state.IncrementHaltRequests(); + return default; + } + + public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ClearStateAsync(new ScopeId(this._executorId, scopeName)); + + public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.WriteStateAsync(new ScopeId(this._executorId, scopeName), key, value); + + public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadStateAsync(new ScopeId(this._executorId, scopeName), key); + + public ValueTask ReadOrInitStateAsync(string key, Func initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadOrInitStateAsync(new ScopeId(this._executorId, scopeName), key, initialStateFactory); + + public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) + => this.StateManager.ReadKeysAsync(new ScopeId(this._executorId, scopeName)); + + public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) + { + this.SentMessages.Enqueue(message); + return default; + } + + public IReadOnlyDictionary? TraceContext => null; +} diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj index df161719c98..17ca46e4afd 100644 --- a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj @@ -1,7 +1,8 @@ - + $(ProjectsTargetFrameworks) + $(ProjectsDebugTargetFrameworks) True $(NoWarn);OPENAI001; @@ -12,6 +13,7 @@ + diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs new file mode 100644 index 00000000000..c5a683ec2d7 --- /dev/null +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantClientExtensionsTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Assistants; +using OpenAI.Files; +using OpenAI.VectorStores; +using Shared.IntegrationTests; + +namespace OpenAIAssistant.IntegrationTests; + +public class OpenAIAssistantClientExtensionsTests +{ + private static readonly OpenAIConfiguration s_config = TestConfiguration.LoadSection(); + private readonly AssistantClient _assistantClient = new OpenAIClient(s_config.ApiKey).GetAssistantClient(); + private readonly OpenAIFileClient _fileClient = new OpenAIClient(s_config.ApiKey).GetOpenAIFileClient(); + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithAIFunctionTool_InvokesFunctionAsync(string createMechanism) + { + // Arrange + const string AgentInstructions = "You are a helpful weather assistant. Always call the GetWeather function to answer questions about weather."; + + static string GetWeather(string location) => $"The weather in {location} is sunny with a high of 23C."; + var weatherFunction = AIFunctionFactory.Create(GetWeather, nameof(GetWeather)); + + // Act + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: AgentInstructions, + tools: [weatherFunction])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: AgentInstructions, + tools: [weatherFunction]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Trigger function call. + var response = await agent.RunAsync("What is the weather like in Amsterdam?"); + var text = response.Text; + + // Assert + Assert.Contains("Amsterdam", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sunny", text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("23", text, StringComparison.OrdinalIgnoreCase); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithHostedCodeInterpreter_RunsCodeAsync(string createMechanism) + { + // Arrange + const string Instructions = "Use the Code Interpreter Tool to run the uploaded python file and respond only with the secret number."; + + // Create a python file that prints a known value. + var codeFilePath = Path.GetTempFileName() + "openai_secret_number.py"; + File.WriteAllText( + path: codeFilePath, + contents: "print(\"OPENAI_SECRET=13579\")" // Deterministic output we will look for. + ); + + // Upload file to OpenAI Assistants file store for use with the Code Interpreter. + var uploadResult = await this._fileClient.UploadFileAsync(codeFilePath, FileUploadPurpose.Assistants); + string uploadedFileId = uploadResult.Value.Id; + var codeInterpreterTool = new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedFileId)] }; + + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [codeInterpreterTool])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [codeInterpreterTool])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: Instructions, + tools: [codeInterpreterTool]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + var response = await agent.RunAsync("What is the OPENAI_SECRET number?"); + var text = response.ToString(); + Assert.Contains("13579", text); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + await this._fileClient.DeleteFileAsync(uploadedFileId); + File.Delete(codeFilePath); + } + } + + [Theory] + [InlineData("CreateWithChatClientAgentOptionsAsync")] + [InlineData("CreateWithChatClientAgentOptionsSync")] + [InlineData("CreateWithParamsAsync")] + public async Task CreateAIAgentAsync_WithHostedFileSearchTool_SearchesFilesAsync(string createMechanism) + { + // Arrange. + const string Instructions = """ + You are a helpful agent that can help fetch data from files you know about. + Use the File Search Tool to look up codes for words. + Do not answer a question unless you can find the answer using the File Search Tool. + """; + + // Create a local file with deterministic content and upload it. + var searchFilePath = Path.GetTempFileName() + "wordcodelookup.txt"; + File.WriteAllText( + path: searchFilePath, + contents: "The word 'apple' uses the code 442345, while the word 'banana' uses the code 673457."); + var uploadResult = await this._fileClient.UploadFileAsync(searchFilePath, FileUploadPurpose.Assistants); + string uploadedFileId = uploadResult.Value.Id; + + // Create a vector store backing the file search (HostedFileSearchTool requires a vector store id). + var vectorStoreClient = new OpenAIClient(s_config.ApiKey).GetVectorStoreClient(); + var vectorStoreCreate = await vectorStoreClient.CreateVectorStoreAsync(options: new VectorStoreCreationOptions() + { + Name = "WordCodeLookup_VectorStore", + FileIds = { uploadedFileId } + }); + string vectorStoreId = vectorStoreCreate.Value.Id; + + var fileSearchTool = new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreId)] }; + + var agent = createMechanism switch + { + "CreateWithChatClientAgentOptionsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [fileSearchTool])), + "CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent( + model: s_config.ChatModelId!, + options: new ChatClientAgentOptions( + instructions: Instructions, + tools: [fileSearchTool])), + "CreateWithParamsAsync" => await this._assistantClient.CreateAIAgentAsync( + model: s_config.ChatModelId!, + instructions: Instructions, + tools: [fileSearchTool]), + _ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}") + }; + + try + { + // Act - ask about banana code which must be retrieved via file search. + var response = await agent.RunAsync("Can you give me the documented code for 'banana'?"); + var text = response.ToString(); + Assert.Contains("673457", text); + } + finally + { + await this._assistantClient.DeleteAssistantAsync(agent.Id); + await vectorStoreClient.DeleteVectorStoreAsync(vectorStoreId); + await this._fileClient.DeleteFileAsync(uploadedFileId); + File.Delete(searchFilePath); + } + } +} diff --git a/python/.github/instructions/python.instructions.md b/python/.github/instructions/python.instructions.md new file mode 100644 index 00000000000..4668f35efb2 --- /dev/null +++ b/python/.github/instructions/python.instructions.md @@ -0,0 +1,21 @@ +--- +applyTo: '**/agent-framework/python/**' +--- +- When verifying logic with unit tests, run only the related tests, not the entire test suite. +- For new tests and samples, review existing ones to understand the coding style and reuse it. +- When generating new functions, always specify the function return type and parameter types. +- Do not use `Optional`; use `Type | None` instead. +- Before running any commands to execute or test the code, ensure that all problems, compilation errors, and warnings are resolved. +- When formatting files, format only the files you changed or are currently working on; do not format the entire codebase. +- Do not mark new tests with `@pytest.mark.asyncio`. +- If you need debug information to understand an issue, use print statements as needed and remove them when testing is complete. +- Avoid adding excessive comments. +- When working with samples, make sure to update the associated README files with the latest information. These files are usually located in the same folder as the sample or in one of its parent folders. + +Sample structure: +1. Copyright header: `# Copyright (c) Microsoft. All rights reserved.` +2. Required imports. +3. Short description about the sample: `"""This sample demonstrates..."""` +4. Helper functions. +5. Main functions that demonstrate the functionality. If it is a single scenario, use a `main` function. If there are multiple scenarios, define separate functions and add a `main` function that invokes all scenarios. +6. Place `if __name__ == "__main__": asyncio.run(main())` at the end of the sample file to make the example executable. diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 1fa1c52b479..8bb6ebcd759 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,9 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0b251016] - 2025-10-16 + +### Added + +- Add Purview Middleware ([#1142](https://github.com/microsoft/agent-framework/pull/1142)) +- Added URL Citation Support to Azure AI Agent ([#1397](https://github.com/microsoft/agent-framework/pull/1397)) +- Added MCP headers for AzureAI ([#1506](https://github.com/microsoft/agent-framework/pull/1506)) +- Add Function Approval UI to DevUI ([#1401](https://github.com/microsoft/agent-framework/pull/1401)) +- Added function approval example with streaming ([#1365](https://github.com/microsoft/agent-framework/pull/1365)) +- Added A2A AuthInterceptor Support ([#1317](https://github.com/microsoft/agent-framework/pull/1317)) +- Added example with MCP and authentication ([#1389](https://github.com/microsoft/agent-framework/pull/1389)) +- Added sample with Foundry Redteams ([#1306](https://github.com/microsoft/agent-framework/pull/1306)) +- Added AzureAI Agent AI Search Sample ([#1281](https://github.com/microsoft/agent-framework/pull/1281)) +- Added AzureAI Bing Connection Name Support ([#1364](https://github.com/microsoft/agent-framework/pull/1364)) + +### Changed + +- Enhanced documentation for dependency injection and serialization features ([#1324](https://github.com/microsoft/agent-framework/pull/1324)) +- Update README to list all available examples ([#1394](https://github.com/microsoft/agent-framework/pull/1394)) +- Reorganize workflows modules ([#1282](https://github.com/microsoft/agent-framework/pull/1282)) +- Improved thread serialization and deserialization with better tests ([#1316](https://github.com/microsoft/agent-framework/pull/1316)) +- Included existing agent definition in requests to Azure AI ([#1285](https://github.com/microsoft/agent-framework/pull/1285)) +- DevUI - Internal Refactor, Conversations API support, and performance improvements ([#1235](https://github.com/microsoft/agent-framework/pull/1235)) +- Refactor `RequestInfoExecutor` ([#1403](https://github.com/microsoft/agent-framework/pull/1403)) + +### Fixed + +- Fix AI Search Tool Sample and improve AI Search Exceptions ([#1206](https://github.com/microsoft/agent-framework/pull/1206)) +- Fix Failure with Function Approval Messages in Chat Clients ([#1322](https://github.com/microsoft/agent-framework/pull/1322)) +- Fix deadlock in Magentic workflow ([#1325](https://github.com/microsoft/agent-framework/pull/1325)) +- Fix tool call content not showing up in workflow events ([#1290](https://github.com/microsoft/agent-framework/pull/1290)) +- Fixed instructions duplication in model clients ([#1332](https://github.com/microsoft/agent-framework/pull/1332)) +- Agent Name Sanitization ([#1523](https://github.com/microsoft/agent-framework/pull/1523)) + ## [1.0.0b251007] - 2025-10-07 ### Added + - Added method to expose agent as MCP server ([#1248](https://github.com/microsoft/agent-framework/pull/1248)) - Add PDF file support to OpenAI content parser with filename mapping ([#1121](https://github.com/microsoft/agent-framework/pull/1121)) - Sample on integration of Azure OpenAI Responses Client with a local MCP server ([#1215](https://github.com/microsoft/agent-framework/pull/1215)) @@ -21,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add semantic-kernel to agent-framework migration code samples ([#1045](https://github.com/microsoft/agent-framework/pull/1045)) ### Changed + - [BREAKING] Parameter naming and other fixes ([#1255](https://github.com/microsoft/agent-framework/pull/1255)) - [BREAKING] Introduce add_agent functionality and added output_response to AgentExecutor; agent streaming behavior to follow workflow invocation ([#1184](https://github.com/microsoft/agent-framework/pull/1184)) - OpenAI Clients accepting api_key callback ([#1139](https://github.com/microsoft/agent-framework/pull/1139)) @@ -38,6 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Python: Foundry Agent Completeness ([#954](https://github.com/microsoft/agent-framework/pull/954)) ### Fixed + - Ollama + azureai openapi samples fix ([#1244](https://github.com/microsoft/agent-framework/pull/1244)) - Fix multimodal input sample: Document required environment variables and configuration options ([#1088](https://github.com/microsoft/agent-framework/pull/1088)) - Fix Azure AI Getting Started samples: Improve documentation and code readability ([#1089](https://github.com/microsoft/agent-framework/pull/1089)) @@ -50,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0b251001] - 2025-10-01 ### Added + - First release of Agent Framework for Python - agent-framework-core: Main abstractions, types and implementations for OpenAI and Azure OpenAI - agent-framework-azure-ai: Integration with Azure AI Foundry Agents @@ -61,6 +99,7 @@ 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.0b251007...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...HEAD +[1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016 [1.0.0b251007]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251001...python-1.0.0b251007 [1.0.0b251001]: https://github.com/microsoft/agent-framework/releases/tag/python-1.0.0b251001 diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 7b313c8f542..e353a7c7faf 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -9,6 +9,7 @@ import httpx from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card +from a2a.client.auth.interceptor import AuthInterceptor from a2a.types import ( AgentCard, Artifact, @@ -78,6 +79,7 @@ def __init__( url: str | None = None, client: Client | None = None, http_client: httpx.AsyncClient | None = None, + auth_interceptor: AuthInterceptor | None = None, **kwargs: Any, ) -> None: """Initialize the A2AAgent. @@ -90,6 +92,7 @@ def __init__( url: The URL for the A2A server. client: The A2A client for the agent. http_client: Optional httpx.AsyncClient to use. + auth_interceptor: Optional authentication interceptor for secured endpoints. kwargs: any additional properties, passed to BaseAgent. """ super().__init__(id=id, name=name, description=description, **kwargs) @@ -123,7 +126,8 @@ def __init__( supported_transports=[TransportProtocol.jsonrpc], ) factory = ClientFactory(config) - self.client = factory.create(agent_card) + interceptors = [auth_interceptor] if auth_interceptor is not None else None + self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore async def __aenter__(self) -> "A2AAgent": """Async context manager entry.""" diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 92b9b0c7a6e..0d3360309c9 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.0b251007" +version = "1.0.0b251016" 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/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 34a5384ba72..b8fe97be601 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -497,3 +497,21 @@ def test_a2a_parts_to_contents_with_hosted_file_uri() -> None: assert isinstance(contents[0], UriContent) assert contents[0].uri == "hosted://storage/document.pdf" assert contents[0].media_type == "" # Converted None to empty string + + +def test_auth_interceptor_parameter() -> None: + """Test that auth_interceptor parameter is accepted without errors.""" + # Create a mock auth interceptor + mock_auth_interceptor = MagicMock() + + # Test that A2AAgent can be created with auth_interceptor parameter + # Using url parameter for simplicity + agent = A2AAgent( + name="test-agent", + url="https://test-agent.example.com", + auth_interceptor=mock_auth_interceptor, + ) + + # Verify the agent was created successfully + assert agent.name == "test-agent" + assert agent.client is not None diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 786cc14a6c9..23db4894c69 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -14,6 +14,7 @@ ChatOptions, ChatResponse, ChatResponseUpdate, + CitationAnnotation, Contents, DataContent, FunctionApprovalRequestContent, @@ -28,6 +29,7 @@ HostedWebSearchTool, Role, TextContent, + TextSpanRegion, ToolMode, ToolProtocol, UriContent, @@ -42,6 +44,7 @@ from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException from agent_framework.observability import use_observability from azure.ai.agents.models import ( + Agent, AgentsNamedToolChoice, AgentsNamedToolChoiceType, AgentsToolChoiceOptionMode, @@ -55,9 +58,12 @@ CodeInterpreterToolDefinition, FileSearchTool, FunctionName, + FunctionToolDefinition, ListSortOrder, McpTool, MessageDeltaChunk, + MessageDeltaTextContent, + MessageDeltaTextUrlCitationAnnotation, MessageImageUrlParam, MessageInputContentBlock, MessageInputImageUrlBlock, @@ -251,6 +257,7 @@ def __init__( self.thread_id = thread_id self._should_delete_agent = False # Track whether we should delete the agent self._should_close_client = should_close_client # Track whether we should close client connection + self._agent_definition: Agent | None = None # Cached definition for existing agent async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None: """Use this method to setup tracing in your Azure AI Project. @@ -375,6 +382,7 @@ async def _get_agent_id_or_create(self, run_options: dict[str, Any] | None = Non args["response_format"] = run_options["response_format"] created_agent = await self.project_client.agents.create_agent(**args) self.agent_id = str(created_agent.id) + self._agent_definition = created_agent self._should_delete_agent = True return self.agent_id @@ -476,6 +484,37 @@ async def _prepare_thread( # and remove until here. return thread_id + def _extract_url_citations(self, message_delta_chunk: MessageDeltaChunk) -> list[CitationAnnotation]: + """Extract URL citations from MessageDeltaChunk.""" + url_citations: list[CitationAnnotation] = [] + + # Process each content item in the delta to find citations + for content in message_delta_chunk.delta.content: + if isinstance(content, MessageDeltaTextContent) and content.text and content.text.annotations: + for annotation in content.text.annotations: + if isinstance(annotation, MessageDeltaTextUrlCitationAnnotation): + # Create annotated regions only if both start and end indices are available + annotated_regions = [] + if annotation.start_index and annotation.end_index: + annotated_regions = [ + TextSpanRegion( + start_index=annotation.start_index, + end_index=annotation.end_index, + ) + ] + + # Create CitationAnnotation from AzureAI annotation + citation = CitationAnnotation( + title=getattr(annotation.url_citation, "title", None), + url=annotation.url_citation.url, + snippet=None, + annotated_regions=annotated_regions, + raw_representation=annotation, + ) + url_citations.append(citation) + + return url_citations + async def _process_stream( self, stream: AsyncAgentRunStream[AsyncAgentEventHandler[Any]] | AsyncAgentEventHandler[Any], thread_id: str ) -> AsyncIterable[ChatResponseUpdate]: @@ -488,9 +527,21 @@ async def _process_stream( case MessageDeltaChunk(): # only one event_type: AgentStreamEvent.THREAD_MESSAGE_DELTA role = Role.USER if event_data.delta.role == MessageRole.USER else Role.ASSISTANT + + # Extract URL citations from the delta chunk + url_citations = self._extract_url_citations(event_data) + + # Create contents with citations if any exist + citation_content: list[Contents] = [] + if event_data.text or url_citations: + text_content_obj = TextContent(text=event_data.text or "") + if url_citations: + text_content_obj.annotations = url_citations + citation_content.append(text_content_obj) + yield ChatResponseUpdate( role=role, - text=event_data.text, + contents=citation_content if citation_content else None, conversation_id=thread_id, message_id=response_id, raw_representation=event_data, @@ -514,11 +565,13 @@ async def _process_stream( "submit_tool_outputs", "submit_tool_approval", ]: - contents = self._create_function_call_contents(event_data, response_id) - if contents: + function_call_contents = self._create_function_call_contents( + event_data, response_id + ) + if function_call_contents: yield ChatResponseUpdate( role=Role.ASSISTANT, - contents=contents, + contents=function_call_contents, conversation_id=thread_id, message_id=response_id, raw_representation=event_data, @@ -585,22 +638,22 @@ async def _process_stream( tool_call.code_interpreter, RunStepDeltaCodeInterpreterDetailItemObject, ): - contents = [] + code_contents: list[Contents] = [] if tool_call.code_interpreter.input is not None: logger.debug(f"Code Interpreter Input: {tool_call.code_interpreter.input}") if tool_call.code_interpreter.outputs is not None: for output in tool_call.code_interpreter.outputs: if isinstance(output, RunStepDeltaCodeInterpreterLogOutput) and output.logs: - contents.append(TextContent(text=output.logs)) + code_contents.append(TextContent(text=output.logs)) if ( isinstance(output, RunStepDeltaCodeInterpreterImageOutput) and output.image is not None and output.image.file_id is not None ): - contents.append(HostedFileContent(file_id=output.image.file_id)) + code_contents.append(HostedFileContent(file_id=output.image.file_id)) yield ChatResponseUpdate( role=Role.ASSISTANT, - contents=contents, + contents=code_contents, conversation_id=thread_id, message_id=response_id, raw_representation=tool_call.code_interpreter, @@ -669,6 +722,26 @@ async def _cleanup_agent_if_needed(self) -> None: self.agent_id = None self._should_delete_agent = False + async def _load_agent_definition_if_needed(self) -> Agent | None: + """Load and cache agent details if not already loaded.""" + if self._agent_definition is None and self.agent_id is not None: + self._agent_definition = await self.project_client.agents.get_agent(self.agent_id) + return self._agent_definition + + def _prepare_tool_choice(self, chat_options: ChatOptions) -> None: + """Prepare the tools and tool choice for the chat options. + + Args: + chat_options: The chat options to prepare. + """ + chat_tool_mode = chat_options.tool_choice + if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none": + chat_options.tools = None + chat_options.tool_choice = ToolMode.NONE.mode + return + + chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode + async def _create_run_options( self, messages: MutableSequence[ChatMessage], @@ -677,6 +750,8 @@ async def _create_run_options( ) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]: run_options: dict[str, Any] = {**kwargs} + agent_definition = await self._load_agent_definition_if_needed() + if chat_options is not None: run_options["max_completion_tokens"] = chat_options.max_tokens if chat_options.model_id is not None: @@ -687,11 +762,21 @@ async def _create_run_options( run_options["temperature"] = chat_options.temperature run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls + tool_definitions: list[ToolDefinition | dict[str, Any]] = [] + + # Add tools from existing agent + if agent_definition is not None: + # Don't include function tools, since they will be passed through chat_options.tools + agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)] + if agent_tools: + tool_definitions.extend(agent_tools) + if agent_definition.tool_resources: + run_options["tool_resources"] = agent_definition.tool_resources + if chat_options.tool_choice is not None: if chat_options.tool_choice != "none" and chat_options.tools: - tool_definitions = await self._prep_tools(chat_options.tools, run_options) - if tool_definitions: - run_options["tools"] = tool_definitions + # Add run tools + tool_definitions.extend(await self._prep_tools(chat_options.tools, run_options)) # Handle MCP tool resources for approval mode mcp_tools = [tool for tool in chat_options.tools if isinstance(tool, HostedMCPTool)] @@ -701,6 +786,10 @@ async def _create_run_options( server_label = mcp_tool.name.replace(" ", "_") mcp_resource: dict[str, Any] = {"server_label": server_label} + # Add headers if they exist + if mcp_tool.headers: + mcp_resource["headers"] = mcp_tool.headers + if mcp_tool.approval_mode is not None: match mcp_tool.approval_mode: case str(): @@ -740,6 +829,9 @@ async def _create_run_options( function=FunctionName(name=chat_options.tool_choice.required_function_name), ) + if tool_definitions: + run_options["tools"] = tool_definitions + if chat_options.response_format is not None: run_options["response_format"] = ResponseFormatJsonSchemaType( json_schema=ResponseFormatJsonSchema( @@ -748,7 +840,7 @@ async def _create_run_options( ) ) - instructions: list[str] = [chat_options.instructions] if chat_options and chat_options.instructions else [] + instructions: list[str] = [] required_action_results: list[FunctionResultContent | FunctionApprovalResponseContent] | None = None additional_messages: list[ThreadMessageOptions] | None = None @@ -790,6 +882,14 @@ async def _create_run_options( if additional_messages is not None: run_options["additional_messages"] = additional_messages + # Add instruction from existing agent at the beginning + if ( + agent_definition is not None + and agent_definition.instructions + and agent_definition.instructions not in instructions + ): + instructions.insert(0, agent_definition.instructions) + if len(instructions) > 0: run_options["instructions"] = "".join(instructions) @@ -815,8 +915,9 @@ async def _prep_tools( config_args["market"] = market if set_lang := additional_props.get("set_lang"): config_args["set_lang"] = set_lang - # Bing Grounding + # Bing Grounding (support both connection_id and connection_name) connection_id = additional_props.get("connection_id") or os.getenv("BING_CONNECTION_ID") + connection_name = additional_props.get("connection_name") or os.getenv("BING_CONNECTION_NAME") # Custom Bing Search custom_connection_name = additional_props.get("custom_connection_name") or os.getenv( "BING_CUSTOM_CONNECTION_NAME" @@ -825,8 +926,26 @@ async def _prep_tools( "BING_CUSTOM_INSTANCE_NAME" ) bing_search: BingGroundingTool | BingCustomSearchTool | None = None - if connection_id and not custom_connection_name and not custom_configuration_name: - bing_search = BingGroundingTool(connection_id=connection_id, **config_args) + if ( + (connection_id or connection_name) + and not custom_connection_name + and not custom_configuration_name + ): + if connection_id: + conn_id = connection_id + elif connection_name: + try: + bing_connection = await self.project_client.connections.get(name=connection_name) + except HttpResponseError as err: + raise ServiceInitializationError( + f"Bing connection '{connection_name}' not found in the Azure AI Project.", + err, + ) from err + else: + conn_id = bing_connection.id + else: + raise ServiceInitializationError("Neither connection_id nor connection_name provided.") + bing_search = BingGroundingTool(connection_id=conn_id, **config_args) if custom_connection_name and custom_configuration_name: try: bing_custom_connection = await self.project_client.connections.get( @@ -845,10 +964,11 @@ async def _prep_tools( ) if not bing_search: raise ServiceInitializationError( - "Bing search tool requires either a 'connection_id' for Bing Grounding " + "Bing search tool requires either 'connection_id' or 'connection_name' for Bing Grounding " "or both 'custom_connection_name' and 'custom_instance_name' for Custom Bing Search. " - "These can be provided via the tool's additional_properties or environment variables: " - "'BING_CONNECTION_ID', 'BING_CUSTOM_CONNECTION_NAME', 'BING_CUSTOM_INSTANCE_NAME'" + "These can be provided via additional_properties or environment variables: " + "'BING_CONNECTION_ID', 'BING_CONNECTION_NAME', 'BING_CUSTOM_CONNECTION_NAME', " + "'BING_CUSTOM_INSTANCE_NAME'" ) tool_definitions.extend(bing_search.definitions) case HostedCodeInterpreterTool(): @@ -882,7 +1002,7 @@ async def _prep_tools( azs_conn_id = await self.project_client.connections.get_default( ConnectionType.AZURE_AI_SEARCH ) - except HttpResponseError as err: + except ValueError as err: raise ServiceInitializationError( "No default Azure AI Search connection found in the Azure AI Project. " "Please create one or provide vector store inputs for the file search tool.", @@ -907,6 +1027,10 @@ async def _prep_tools( filter=additional_props.get("filter", ""), ) tool_definitions.extend(ai_search.definitions) + # Add tool resources for Azure AI Search + if run_options is not None: + run_options.setdefault("tool_resources", {}) + run_options["tool_resources"].update(ai_search.resources) case ToolDefinition(): tool_definitions.append(tool) case dict(): diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index ce2bd9573f2..e38e9c430e3 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.0b251007" +version = "1.0.0b251016" 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/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 5b4e0302e8e..b590557224f 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -18,6 +18,7 @@ ChatOptions, ChatResponse, ChatResponseUpdate, + CitationAnnotation, FunctionApprovalRequestContent, FunctionApprovalResponseContent, FunctionCallContent, @@ -36,6 +37,9 @@ from azure.ai.agents.models import ( CodeInterpreterToolDefinition, FileInfo, + MessageDeltaChunk, + MessageDeltaTextContent, + MessageDeltaTextUrlCitationAnnotation, RequiredFunctionToolCall, RequiredMcpToolCall, RunStatus, @@ -46,7 +50,7 @@ ) from azure.ai.projects.models import ConnectionType from azure.core.credentials_async import AsyncTokenCredential -from azure.core.exceptions import HttpResponseError +from azure.core.exceptions import HttpResponseError, ResourceNotFoundError from azure.identity.aio import AzureCliCredential from pydantic import BaseModel, Field, ValidationError from pytest import MonkeyPatch @@ -81,11 +85,12 @@ def create_test_azure_ai_chat_client( client.project_client = mock_ai_project_client client.credential = None client.agent_id = agent_id - client.agent_name = None + client.agent_name = agent_name client.model_id = azure_ai_settings.model_deployment_name client.thread_id = thread_id - client._should_delete_agent = should_delete_agent - client._should_close_client = False + client._should_delete_agent = should_delete_agent # type: ignore + client._should_close_client = False # type: ignore + client._agent_definition = None # type: ignore client.additional_properties = {} client.middleware = None @@ -297,6 +302,9 @@ async def test_azure_ai_chat_client_tool_results_without_thread_error_via_public """Test that tool results without thread ID raise error through public API.""" chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + # Mock get_agent + mock_ai_project_client.agents.get_agent = AsyncMock(return_value=None) + # Create messages with tool results but no thread/conversation ID messages = [ ChatMessage(role=Role.USER, text="Hello"), @@ -315,6 +323,9 @@ async def test_azure_ai_chat_client_thread_management_through_public_api(mock_ai """Test thread creation and management through public API.""" chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + # Mock get_agent to avoid the async error + mock_ai_project_client.agents.get_agent = AsyncMock(return_value=None) + mock_thread = MagicMock() mock_thread.id = "new-thread-456" mock_ai_project_client.agents.threads.create = AsyncMock(return_value=mock_thread) @@ -451,6 +462,9 @@ async def test_azure_ai_chat_client_create_run_options_with_image_content(mock_a chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + # Mock get_agent + mock_ai_project_client.agents.get_agent = AsyncMock(return_value=None) + image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg") messages = [ChatMessage(role=Role.USER, contents=[image_content])] @@ -544,6 +558,19 @@ async def test_azure_ai_chat_client_create_run_options_with_messages(mock_ai_pro assert len(run_options["additional_messages"]) == 1 # Only user message +async def test_azure_ai_chat_client_instructions_sent_once(mock_ai_project_client: MagicMock) -> None: + """Ensure instructions are only sent once for AzureAIAgentClient.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client) + + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + messages = chat_client.prepare_messages([ChatMessage(role=Role.USER, text="Hello")], chat_options) + + run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore + + assert run_options.get("instructions") == instructions + + async def test_azure_ai_chat_client_inner_get_response(mock_ai_project_client: MagicMock) -> None: """Test _inner_get_response method.""" chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") @@ -804,6 +831,38 @@ async def test_azure_ai_chat_client_create_run_options_mcp_never_require(mock_ai assert mcp_resource["require_approval"] == "never" +async def test_azure_ai_chat_client_create_run_options_mcp_with_headers(mock_ai_project_client: MagicMock) -> None: + """Test _create_run_options with HostedMCPTool having headers.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client) + + # Test with headers + headers = {"Authorization": "Bearer DUMMY_TOKEN", "X-API-Key": "DUMMY_KEY"} + mcp_tool = HostedMCPTool( + name="Test MCP Tool", url="https://example.com/mcp", headers=headers, approval_mode="never_require" + ) + + messages = [ChatMessage(role=Role.USER, text="Hello")] + chat_options = ChatOptions(tools=[mcp_tool], tool_choice="auto") + + with patch("agent_framework_azure_ai._chat_client.McpTool") as mock_mcp_tool_class: + # Mock _prep_tools to avoid actual tool preparation + mock_mcp_tool_instance = MagicMock() + mock_mcp_tool_instance.definitions = [{"type": "mcp", "name": "test_mcp"}] + mock_mcp_tool_class.return_value = mock_mcp_tool_instance + + run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore + + # Verify tool_resources is created with headers + assert "tool_resources" in run_options + assert "mcp" in run_options["tool_resources"] + assert len(run_options["tool_resources"]["mcp"]) == 1 + + mcp_resource = run_options["tool_resources"]["mcp"][0] + assert mcp_resource["server_label"] == "Test_MCP_Tool" + assert mcp_resource["require_approval"] == "never" + assert mcp_resource["headers"] == headers + + async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ai_project_client: MagicMock) -> None: """Test _prep_tools with HostedWebSearchTool using Bing Grounding.""" @@ -811,7 +870,7 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ai web_search_tool = HostedWebSearchTool( additional_properties={ - "connection_id": "test-connection-id", + "connection_name": "test-connection-name", "count": 5, "freshness": "Day", "market": "en-US", @@ -819,6 +878,11 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ai } ) + # Mock connection get + mock_connection = MagicMock() + mock_connection.id = "test-connection-id" + mock_ai_project_client.connections.get = AsyncMock(return_value=mock_connection) + # Mock BingGroundingTool with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: mock_bing_tool = MagicMock() @@ -834,6 +898,35 @@ async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding(mock_ai ) +async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding_with_connection_id( + mock_ai_project_client: MagicMock, +) -> None: + """Test _prep_tools with HostedWebSearchTool using Bing Grounding with connection_id (no HTTP call).""" + + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + web_search_tool = HostedWebSearchTool( + additional_properties={ + "connection_id": "direct-connection-id", + "count": 3, + } + ) + + # Mock BingGroundingTool + with patch("agent_framework_azure_ai._chat_client.BingGroundingTool") as mock_bing_grounding: + mock_bing_tool = MagicMock() + mock_bing_tool.definitions = [{"type": "bing_grounding"}] + mock_bing_grounding.return_value = mock_bing_tool + + result = await chat_client._prep_tools([web_search_tool]) # type: ignore + + assert len(result) == 1 + assert result[0] == {"type": "bing_grounding"} + # Verify that connection_id was used directly (no HTTP call to connections.get) + mock_ai_project_client.connections.get.assert_not_called() + mock_bing_grounding.assert_called_once_with(connection_id="direct-connection-id", count=3) + + async def test_azure_ai_chat_client_prep_tools_web_search_custom_bing(mock_ai_project_client: MagicMock) -> None: """Test _prep_tools with HostedWebSearchTool using Custom Bing Search.""" @@ -889,15 +982,23 @@ async def test_azure_ai_chat_client_prep_tools_web_search_custom_bing_connection await chat_client._prep_tools([web_search_tool]) # type: ignore -async def test_azure_ai_chat_client_prep_tools_web_search_missing_config(mock_ai_project_client: MagicMock) -> None: - """Test _prep_tools with HostedWebSearchTool missing required configuration.""" +async def test_azure_ai_chat_client_prep_tools_web_search_bing_grounding_connection_error( + mock_ai_project_client: MagicMock, +) -> None: + """Test _prep_tools with HostedWebSearchTool when Bing Grounding connection is not found.""" chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") - # Web search tool with no connection configuration - web_search_tool = HostedWebSearchTool() + web_search_tool = HostedWebSearchTool( + additional_properties={ + "connection_name": "nonexistent-bing-connection", + } + ) - with pytest.raises(ServiceInitializationError, match="Bing search tool requires either a 'connection_id'"): + # Mock connection get to raise HttpResponseError + mock_ai_project_client.connections.get = AsyncMock(side_effect=HttpResponseError("Connection not found")) + + with pytest.raises(ServiceInitializationError, match="Bing connection 'nonexistent-bing-connection' not found"): await chat_client._prep_tools([web_search_tool]) # type: ignore @@ -1002,8 +1103,8 @@ async def test_azure_ai_chat_client_prep_tools_file_search_no_connection(mock_ai file_search_tool = HostedFileSearchTool(additional_properties={"index_name": "test-index"}) - # Mock connections.get_default to raise HttpResponseError - mock_ai_project_client.connections.get_default = AsyncMock(side_effect=HttpResponseError("No connection found")) + # Mock connections.get_default to raise ValueError + mock_ai_project_client.connections.get_default = AsyncMock(side_effect=ValueError("No connection found")) with pytest.raises(ServiceInitializationError, match="No default Azure AI Search connection found"): await chat_client._prep_tools([file_search_tool]) # type: ignore @@ -1374,6 +1475,154 @@ async def test_azure_ai_chat_client_create_agent_stream_submit_tool_outputs( assert final_thread_id == "test-thread" +def test_azure_ai_chat_client_extract_url_citations_with_citations(mock_ai_project_client: MagicMock) -> None: + """Test _extract_url_citations with MessageDeltaChunk containing URL citations.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + # Create mock URL citation annotation + mock_url_citation = MagicMock() + mock_url_citation.url = "https://example.com/test" + mock_url_citation.title = "Test Title" + + mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) + mock_annotation.url_citation = mock_url_citation + mock_annotation.start_index = 10 + mock_annotation.end_index = 20 + + # Create mock text content with annotations + mock_text = MagicMock() + mock_text.annotations = [mock_annotation] + + mock_text_content = MagicMock(spec=MessageDeltaTextContent) + mock_text_content.text = mock_text + + # Create mock delta + mock_delta = MagicMock() + mock_delta.content = [mock_text_content] + + # Create mock MessageDeltaChunk + mock_chunk = MagicMock(spec=MessageDeltaChunk) + mock_chunk.delta = mock_delta + + # Call the method + citations = chat_client._extract_url_citations(mock_chunk) # type: ignore + + # Verify results + assert len(citations) == 1 + citation = citations[0] + assert isinstance(citation, CitationAnnotation) + assert citation.url == "https://example.com/test" + assert citation.title == "Test Title" + assert citation.snippet is None + assert citation.annotated_regions is not None + assert len(citation.annotated_regions) == 1 + assert citation.annotated_regions[0].start_index == 10 + assert citation.annotated_regions[0].end_index == 20 + + +def test_azure_ai_chat_client_extract_url_citations_no_citations(mock_ai_project_client: MagicMock) -> None: + """Test _extract_url_citations with MessageDeltaChunk containing no citations.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + # Create mock text content without annotations + mock_text_content = MagicMock(spec=MessageDeltaTextContent) + mock_text_content.text = None # No text, so no annotations + + # Create mock delta + mock_delta = MagicMock() + mock_delta.content = [mock_text_content] + + # Create mock MessageDeltaChunk + mock_chunk = MagicMock(spec=MessageDeltaChunk) + mock_chunk.delta = mock_delta + + # Call the method + citations = chat_client._extract_url_citations(mock_chunk) # type: ignore + + # Verify no citations returned + assert len(citations) == 0 + + +def test_azure_ai_chat_client_extract_url_citations_empty_delta(mock_ai_project_client: MagicMock) -> None: + """Test _extract_url_citations with empty delta content.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + # Create mock delta with empty content + mock_delta = MagicMock() + mock_delta.content = [] + + # Create mock MessageDeltaChunk + mock_chunk = MagicMock(spec=MessageDeltaChunk) + mock_chunk.delta = mock_delta + + # Call the method + citations = chat_client._extract_url_citations(mock_chunk) # type: ignore + + # Verify no citations returned + assert len(citations) == 0 + + +def test_azure_ai_chat_client_extract_url_citations_without_indices(mock_ai_project_client: MagicMock) -> None: + """Test _extract_url_citations with URL citations that don't have start/end indices.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + # Create mock URL citation annotation without indices + mock_url_citation = MagicMock() + mock_url_citation.url = "https://example.com/no-indices" + + mock_annotation = MagicMock(spec=MessageDeltaTextUrlCitationAnnotation) + mock_annotation.url_citation = mock_url_citation + mock_annotation.start_index = None + mock_annotation.end_index = None + + # Create mock text content with annotations + mock_text = MagicMock() + mock_text.annotations = [mock_annotation] + + mock_text_content = MagicMock(spec=MessageDeltaTextContent) + mock_text_content.text = mock_text + + # Create mock delta + mock_delta = MagicMock() + mock_delta.content = [mock_text_content] + + # Create mock MessageDeltaChunk + mock_chunk = MagicMock(spec=MessageDeltaChunk) + mock_chunk.delta = mock_delta + + # Call the method + citations = chat_client._extract_url_citations(mock_chunk) # type: ignore + + # Verify results + assert len(citations) == 1 + citation = citations[0] + assert citation.url == "https://example.com/no-indices" + assert citation.annotated_regions is not None + assert len(citation.annotated_regions) == 0 # No regions when indices are None + + +async def test_azure_ai_chat_client_setup_azure_ai_observability_resource_not_found( + mock_ai_project_client: MagicMock, +) -> None: + """Test setup_azure_ai_observability when Application Insights connection string is not found.""" + chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent") + + # Mock telemetry.get_application_insights_connection_string to raise ResourceNotFoundError + mock_ai_project_client.telemetry.get_application_insights_connection_string = AsyncMock( + side_effect=ResourceNotFoundError("No Application Insights found") + ) + + # Mock logger.warning to capture the warning message + with patch("agent_framework_azure_ai._chat_client.logger") as mock_logger: + await chat_client.setup_azure_ai_observability() + + # Verify warning was logged + mock_logger.warning.assert_called_once_with( + "No Application Insights connection string found for the Azure AI Project, " + "please call setup_observability() manually." + ) + + def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], ) -> str: diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 2ab391489b1..c34778e75ec 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.0b251007" +version = "1.0.0b251016" 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/_agents.py b/python/packages/core/agent_framework/_agents.py index 8177bca4cea..0125adb188d 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import inspect +import re import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from contextlib import AbstractAsyncContextManager, AsyncExitStack @@ -48,6 +49,44 @@ TThreadType = TypeVar("TThreadType", bound="AgentThread") + +def _sanitize_agent_name(agent_name: str | None) -> str | None: + """Sanitize agent name for use as a function name. + + Replaces spaces and special characters with underscores to create + a valid Python identifier. + + Args: + agent_name: The agent name to sanitize. + + Returns: + The sanitized agent name with invalid characters replaced by underscores. + If the input is None, returns None. + If sanitization results in an empty string (e.g., agent_name="@@@"), returns "agent" as a default. + """ + if agent_name is None: + return None + + # Replace any character that is not alphanumeric or underscore with underscore + sanitized = re.sub(r"[^a-zA-Z0-9_]", "_", agent_name) + + # Replace multiple consecutive underscores with a single underscore + sanitized = re.sub(r"_+", "_", sanitized) + + # Remove leading/trailing underscores + sanitized = sanitized.strip("_") + + # Handle empty string case + if not sanitized: + return "agent" + + # Prefix with underscore if the sanitized name starts with a digit + if sanitized and sanitized[0].isdigit(): + sanitized = f"_{sanitized}" + + return sanitized + + __all__ = ["AgentProtocol", "BaseAgent", "ChatAgent"] @@ -396,7 +435,7 @@ def as_tool( if not isinstance(self, AgentProtocol): raise TypeError(f"Agent {self.__class__.__name__} must implement AgentProtocol to be used as a tool") - tool_name = name or self.name + tool_name = name or _sanitize_agent_name(self.name) if tool_name is None: raise ValueError("Agent tool name cannot be None. Either provide a name parameter or set the agent's name.") tool_description = description or self.description or "" @@ -404,7 +443,8 @@ def as_tool( # Create dynamic input model with the specified argument name field_info = Field(..., description=argument_description) - input_model = create_model(f"{name or self.name or 'agent'}_task", **{arg_name: (str, field_info)}) # type: ignore[call-overload] + model_name = f"{name or _sanitize_agent_name(self.name) or 'agent'}_task" + input_model = create_model(model_name, **{arg_name: (str, field_info)}) # type: ignore[call-overload] # Check if callback is async once, outside the wrapper is_async_callback = stream_callback is not None and inspect.iscoroutinefunction(stream_callback) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 9df41a5f848..1a38d9030a6 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -2,7 +2,7 @@ import json import re -from collections.abc import MutableMapping +from collections.abc import Mapping, MutableMapping from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable from ._logging import get_logger @@ -20,28 +20,65 @@ class SerializationProtocol(Protocol): """Protocol for objects that support serialization and deserialization. - This protocol defines the interface for objects that can be converted to and from dictionaries. + This protocol defines the interface that classes must implement to be compatible + with the agent framework's serialization system. Any class implementing both + ``to_dict()`` and ``from_dict()`` methods will automatically satisfy this protocol + and can be used seamlessly with other serializable components. + + The protocol enables type safety and duck typing for serializable objects, + ensuring consistent behavior across the framework. Examples: + The framework's ``ChatMessage`` class demonstrates the protocol in action: + .. code-block:: python - from agent_framework import SerializationProtocol + from agent_framework import ChatMessage + from agent_framework._serialization import SerializationProtocol + + # ChatMessage implements SerializationProtocol via SerializationMixin + user_msg = ChatMessage(role="user", text="What's the weather like today?") - class MySerializable: - def __init__(self, value: str): - self.value = value + # Serialize to dictionary - automatic type identification and nested serialization + msg_dict = user_msg.to_dict() + # Result: { + # "type": "chat_message", + # "role": {"type": "role", "value": "user"}, + # "contents": [{"type": "text_content", "text": "What's the weather like today?"}], + # "message_id": "...", + # "additional_properties": {} + # } - def to_dict(self, **kwargs): - return {"value": self.value} + # Deserialize back to ChatMessage instance - automatic type reconstruction + restored_msg = ChatMessage.from_dict(msg_dict) + print(restored_msg.text) # "What's the weather like today?" + print(restored_msg.role.value) # "user" - @classmethod - def from_dict(cls, value, **kwargs): - return cls(value["value"]) + # Verify protocol compliance (useful for type checking and validation) + assert isinstance(user_msg, SerializationProtocol) + assert isinstance(restored_msg, SerializationProtocol) + + The protocol is also implemented by simpler classes like ``UsageDetails``: + + .. code-block:: python + from agent_framework import UsageDetails - # Verify it implements the protocol - assert isinstance(MySerializable("test"), SerializationProtocol) + # Create usage tracking instance + usage = UsageDetails(input_token_count=150, output_token_count=75, total_token_count=225) + + # Seamless serialization with type preservation + usage_dict = usage.to_dict() + restored_usage = UsageDetails.from_dict(usage_dict) + + # Both satisfy the SerializationProtocol + assert isinstance(usage, SerializationProtocol) + assert restored_usage.total_token_count == 225 + + The protocol ensures consistent serialization behavior across all framework components, + enabling reliable data persistence, API communication, and object reconstruction + throughout the agent framework ecosystem. """ def to_dict(self, **kwargs: Any) -> dict[str, Any]: @@ -74,73 +111,176 @@ def from_dict(cls: type[TProtocol], value: MutableMapping[str, Any], /, **kwargs def is_serializable(value: Any) -> bool: """Check if a value is JSON serializable. + This function tests whether a value can be directly serialized to JSON + without custom encoding. It checks for basic Python types that have + direct JSON equivalents. + Args: - value: The value to check. + value: The value to check for JSON serializability. Returns: - True if the value is JSON serializable, False otherwise. + True if the value is one of the basic JSON-serializable types + (str, int, float, bool, None, list, dict), False otherwise. + + Note: + This function only checks for direct JSON compatibility. Complex objects + that implement ``SerializationProtocol`` require conversion via ``to_dict()`` + before JSON serialization. """ return isinstance(value, (str, int, float, bool, type(None), list, dict)) class SerializationMixin: - """Mixin class providing serialization and deserialization capabilities. + """Mixin class providing comprehensive serialization and deserialization capabilities. - Classes using this mixin should handle MutableMapping inputs in their __init__ method - for any parameters that expect SerializationMixin/SerializationProtocol instances. - The __init__ should check if the value is a MutableMapping and call from_dict() to convert it. + .. note:: + SerializationMixin is in active development. The API may change in future versions + as we continue to improve and extend its functionality. - So take the two classes below as an example. The first purely uses base types, strings in this case. - The second has a param that is of the type of the first class. - Because we setup the __init__ method to handle MutableMapping, - we can pass in a dict to the second class and it will convert it to an instance of the first class. + This mixin enables classes to automatically handle complex serialization scenarios + including nested objects, dependency injection, and type conversion. It provides + robust support for converting objects to/from dictionaries and JSON strings while + maintaining object relationships and handling external dependencies. + + **Key Features:** + + - Automatic serialization of nested SerializationProtocol objects + - Support for lists and dictionaries containing serializable objects + - Dependency injection system for non-serializable external dependencies + - Flexible exclusion of fields from serialization + - Type-safe deserialization with automatic type conversion + + **Constructor Pattern for Nested Objects:** + + Classes using this mixin should handle ``MutableMapping`` inputs in their ``__init__`` method + for any parameters that expect ``SerializationMixin`` or ``SerializationProtocol`` instances. + This enables automatic conversion of dictionaries to proper object instances during deserialization. + + **Dependency Injection System:** + + The mixin supports injecting external dependencies (like database connections, API clients, + or configuration objects) that shouldn't be serialized but are needed at runtime. + Fields marked in ``INJECTABLE`` are excluded during serialization and can be provided + during deserialization via the ``dependencies`` parameter. Examples: + **Nested object serialization with agent thread management:** + .. code-block:: python - class SerializableMixinType(SerializationMixin): - def __init__(self, param1: str, param2: int) -> None: - self.param1 = param1 - self.param2 = param2 + from agent_framework import ChatMessage + from agent_framework._threads import AgentThreadState, ChatMessageStoreState - class MyClass(SerializationMixin): - def __init__( - self, - regular_param: str, - param: SerializableMixinType | MutableMapping[str, Any] | None = None, - ) -> None: - if isinstance(param, MutableMapping): - self.param = self.from_dict(param) - else: - self.param = param - self.regular_param = regular_param + # ChatMessageStoreState handles nested ChatMessage serialization + store_state = ChatMessageStoreState( + messages=[ + ChatMessage(role="user", text="Hello agent"), + ChatMessage(role="assistant", text="Hi! How can I help?"), + ] + ) + # Nested serialization: messages are automatically converted to dicts + store_dict = store_state.to_dict() + # Result: { + # "type": "chat_message_store_state", + # "messages": [ + # {"type": "chat_message", "role": {...}, "contents": [...]}, + # {"type": "chat_message", "role": {...}, "contents": [...]} + # ] + # } - instance = MyClass.from_dict({"regular_param": "value", "param": {"param1": "value1", "param2": 42}}) + # AgentThreadState contains nested ChatMessageStoreState + thread_state = AgentThreadState(chat_message_store_state=store_state) - A more complex use case involves an injectable dependency that is not serialized. - In this case, the dependency is passed in via the dependencies parameter to from_dict/from_json. + # Deep serialization: nested SerializationMixin objects are handled automatically + thread_dict = thread_state.to_dict() + # The chat_message_store_state and its nested messages are all serialized + + # Reconstruction from nested dictionaries with automatic type conversion + # The __init__ method handles MutableMapping -> object conversion: + reconstructed = AgentThreadState.from_dict({ + "chat_message_store_state": {"messages": [{"role": "user", "text": "Hello again"}]} + }) + # chat_message_store_state becomes ChatMessageStoreState instance automatically + + **Framework tools with exclusion patterns:** - Examples: .. code-block:: python - from library import Client + from agent_framework._tools import BaseTool - class MyClass(SerializationMixin): - INJECTABLE = {"client"} + class WeatherTool(BaseTool): + \"\"\"Example tool that extends BaseTool with additional properties exclusion.\"\"\" - During serialization, the field listed as INJECTABLE (and also DEFAULT_EXCLUDE) will be excluded from the output. - Then in deserialization, - the dependencies dict is checked for any keys matching the formats: - - "." - - ".." - where is the type identifier for the class (either the value of the 'type' class variable or - the snake_cased class name if 'type' is not present), - is the name of the parameter in the __init__ method, - is the name of a parameter that is a dict, - and is a key in that dict parameter. + # Inherits DEFAULT_EXCLUDE = {"additional_properties"} from BaseTool + + def __init__(self, name: str, api_key: str, **kwargs): + super().__init__(name=name, description="Get weather information", **kwargs) + self.api_key = api_key # Will be serialized + + # Additional properties are excluded from serialization + self.additional_properties = {"version": "1.0", "internal_config": {...}} + + + weather_tool = WeatherTool(name="get_weather", api_key="secret-key") + + # Serialization excludes additional_properties but includes other fields + tool_dict = weather_tool.to_dict() + # Result: { + # "type": "weather_tool", + # "name": "get_weather", + # "description": "Get weather information", + # "api_key": "secret-key" + # # additional_properties excluded due to DEFAULT_EXCLUDE + # } + + **Agent framework with injectable dependencies:** + + .. code-block:: python + + from agent_framework import BaseAgent + + + class CustomAgent(BaseAgent): + \"\"\"Custom agent extending BaseAgent with additional functionality.\"\"\" + + # Inherits DEFAULT_EXCLUDE = {"additional_properties"} from BaseAgent + + def __init__(self, **kwargs): + super().__init__(name="custom-agent", description="A custom agent", **kwargs) + + # additional_properties stores runtime configuration but isn't serialized + self.additional_properties.update({ + "runtime_context": {...}, + "session_data": {...} + }) + + + agent = CustomAgent( + context_providers=[...], + middleware=[...] + ) + + # Serialization captures agent configuration but excludes runtime data + agent_dict = agent.to_dict() + # Result: { + # "type": "custom_agent", + # "id": "...", + # "name": "custom-agent", + # "description": "A custom agent", + # "context_provider": [...], + # "middleware": [...] + # # additional_properties excluded + # } + + # Agent can be reconstructed with the same configuration + restored_agent = CustomAgent.from_dict(agent_dict) + + This approach enables the agent framework to maintain clean separation between + persistent configuration and transient runtime state, allowing agents and tools + to be serialized for storage or transmission while preserving their functionality. """ DEFAULT_EXCLUDE: ClassVar[set[str]] = set() @@ -149,12 +289,22 @@ class MyClass(SerializationMixin): def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: """Convert the instance and any nested objects to a dictionary. + This method performs deep serialization, automatically converting nested + ``SerializationProtocol`` objects, lists, and dictionaries containing + serializable objects. Non-serializable objects are skipped with debug logging. + + Fields marked in ``DEFAULT_EXCLUDE`` and ``INJECTABLE`` are automatically + excluded from the output, as are any private attributes (starting with '_'). + Keyword Args: - exclude: The set of field names to exclude from serialization. - exclude_none: Whether to exclude None values from the output. Defaults to True. + exclude: Additional field names to exclude from serialization beyond + the default exclusions (``DEFAULT_EXCLUDE`` and ``INJECTABLE``). + exclude_none: Whether to exclude None values from the output. When True, + None values are omitted from the dictionary. Defaults to True. Returns: - Dictionary representation of the instance. + Dictionary representation of the instance including a 'type' field + for type identification during deserialization (unless 'type' is excluded). """ # Combine exclude sets combined_exclude = set(self.DEFAULT_EXCLUDE) @@ -212,72 +362,192 @@ def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) return result - def to_json(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> str: + def to_json(self, *, exclude: set[str] | None = None, exclude_none: bool = True, **kwargs: Any) -> str: """Convert the instance to a JSON string. + This is a convenience method that calls ``to_dict()`` and then serializes + the result using ``json.dumps()``. All the same serialization rules apply + as in ``to_dict()``, including automatic exclusion of injectable dependencies + and deep serialization of nested objects. + Keyword Args: - exclude: The set of field names to exclude from serialization. + exclude: Additional field names to exclude from serialization. exclude_none: Whether to exclude None values from the output. Defaults to True. + **kwargs: Additional keyword arguments passed through to ``json.dumps()``. + Common options include ``indent`` for pretty-printing and + ``ensure_ascii`` for Unicode handling. Returns: JSON string representation of the instance. """ - return json.dumps(self.to_dict(exclude=exclude, exclude_none=exclude_none)) + return json.dumps(self.to_dict(exclude=exclude, exclude_none=exclude_none), **kwargs) @classmethod def from_dict( cls: type[TClass], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None ) -> TClass: - """Create an instance from a dictionary. + """Create an instance from a dictionary with optional dependency injection. + + This method reconstructs an object from its dictionary representation, automatically + handling type conversion and dependency injection. It supports three patterns of + dependency injection to handle different scenarios where external dependencies + need to be provided at deserialization time. Args: value: The dictionary containing the instance data (positional-only). + Must include a 'type' field matching the class type identifier. Keyword Args: - dependencies: The dictionary mapping dependency keys to values. - Keys should be in format ``"."`` or ``".."``. + dependencies: A nested dictionary mapping type identifiers to their injectable dependencies. + The structure varies based on injection pattern: + + - **Simple injection**: ``{"": {"": value}}`` + - **Dict parameter injection**: ``{"": {"": {"": value}}}`` + - **Instance-specific injection**: ``{"": {":": {"": value}}}`` Returns: - New instance of the class. + New instance of the class with injected dependencies. + + Raises: + ValueError: If the 'type' field in the data doesn't match the class type identifier. + + Examples: + **Simple Client Injection** - OpenAI client dependency injection: + + .. code-block:: python + + from agent_framework.openai import OpenAIChatClient + from openai import AsyncOpenAI + + + # OpenAI chat client requires an AsyncOpenAI client instance + # The client is marked as INJECTABLE = {"client"} in OpenAIBase + + # Serialized data contains only the model configuration + client_data = { + "type": "open_ai_chat_client", + "model_id": "gpt-4o-mini", + # client is excluded from serialization + } + + # Provide the OpenAI client during deserialization + openai_client = AsyncOpenAI(api_key="your-api-key") + dependencies = {"open_ai_chat_client": {"client": openai_client}} + + # The chat client is reconstructed with the OpenAI client injected + chat_client = OpenAIChatClient.from_dict(client_data, dependencies=dependencies) + # Now ready to make API calls with the injected client + + **Function Injection for Tools** - AIFunction runtime dependency: + + .. code-block:: python + + from agent_framework import AIFunction + from typing import Annotated + + + # Define a function to be wrapped + async def get_current_weather(location: Annotated[str, "The city name"]) -> str: + # In real implementation, this would call a weather API + return f"Current weather in {location}: 72°F and sunny" + + + # AIFunction has INJECTABLE = {"func"} + function_data = { + "type": "ai_function", + "name": "get_weather", + "description": "Get current weather for a location", + # func is excluded from serialization + } + + # Inject the actual function implementation during deserialization + dependencies = {"ai_function": {"func": get_current_weather}} + + # Reconstruct the AIFunction with the callable injected + weather_func = AIFunction.from_dict(function_data, dependencies=dependencies) + # The function is now callable and ready for agent use + + **Middleware Context Injection** - Agent execution context: + + .. code-block:: python + + from agent_framework._middleware import AgentRunContext + from agent_framework import BaseAgent + + # AgentRunContext has INJECTABLE = {"agent", "result"} + context_data = { + "type": "agent_run_context", + "messages": [{"role": "user", "text": "Hello"}], + "is_streaming": False, + "metadata": {"session_id": "abc123"}, + # agent and result are excluded from serialization + } + + # Inject agent and result during middleware processing + my_agent = BaseAgent(name="test-agent") + dependencies = { + "agent_run_context": { + "agent": my_agent, + "result": None, # Will be populated during execution + } + } + + # Reconstruct context with agent dependency for middleware chain + context = AgentRunContext.from_dict(context_data, dependencies=dependencies) + # Middleware can now access context.agent and process the execution + + This injection system allows the agent framework to maintain clean separation + between serializable configuration and runtime dependencies like API clients, + functions, and execution contexts that cannot or should not be persisted. """ if dependencies is None: dependencies = {} # Get the type identifier - type_id = cls._get_type_identifier() + type_id = cls._get_type_identifier(value) + + if (supplied_type := value.get("type")) and supplied_type != type_id: + raise ValueError(f"Type mismatch: expected '{type_id}', got '{supplied_type}'") # Create a copy of the value dict to work with, filtering out the 'type' key kwargs = {k: v for k, v in value.items() if k != "type"} - # Process dependencies - for dep_key, dep_value in dependencies.items(): - parts = dep_key.split(".") - if len(parts) < 2: - continue - - dep_type = parts[0] - if dep_type != type_id: - continue - - param_name = parts[1] - - # Log debug message if dependency is not in INJECTABLE - if param_name not in cls.INJECTABLE: - logger.debug( - f"Dependency '{param_name}' for type '{type_id}' is not in INJECTABLE set. " - f"Available injectable parameters: {cls.INJECTABLE}" - ) - - if len(parts) == 2: - # Simple parameter: . - kwargs[param_name] = dep_value - elif len(parts) == 3: - # Dict parameter: .. - dict_param_name = parts[1] - key = parts[2] - if dict_param_name not in kwargs: - kwargs[dict_param_name] = {} - kwargs[dict_param_name][key] = dep_value + # Process dependencies using dict-based structure + type_deps = dependencies.get(type_id, {}) + for dep_key, dep_value in type_deps.items(): + # Check if this is an instance-specific dependency (field:name format) + if ":" in dep_key: + field, name = dep_key.split(":", 1) + # Only apply if the instance matches + if kwargs.get(field) == name and isinstance(dep_value, dict): + # Apply instance-specific dependencies + for param_name, param_value in dep_value.items(): + if param_name not in cls.INJECTABLE: + logger.debug( + f"Dependency '{param_name}' for type '{type_id}' is not in INJECTABLE set. " + f"Available injectable parameters: {cls.INJECTABLE}" + ) + # Handle nested dict parameters + if ( + isinstance(param_value, dict) + and param_name in kwargs + and isinstance(kwargs[param_name], dict) + ): + kwargs[param_name].update(param_value) + else: + kwargs[param_name] = param_value + else: + # Regular parameter dependency + if dep_key not in cls.INJECTABLE: + logger.debug( + f"Dependency '{dep_key}' for type '{type_id}' is not in INJECTABLE set. " + f"Available injectable parameters: {cls.INJECTABLE}" + ) + # Handle dict parameters - merge if both are dicts + if isinstance(dep_value, dict) and dep_key in kwargs and isinstance(kwargs[dep_key], dict): + kwargs[dep_key].update(dep_value) + else: + kwargs[dep_key] = dep_value return cls(**kwargs) @@ -285,31 +555,56 @@ def from_dict( def from_json(cls: type[TClass], value: str, /, *, dependencies: MutableMapping[str, Any] | None = None) -> TClass: """Create an instance from a JSON string. + This is a convenience method that parses the JSON string using ``json.loads()`` + and then calls ``from_dict()`` to reconstruct the object. All dependency injection + capabilities are available through the ``dependencies`` parameter. + Args: value: The JSON string containing the instance data (positional-only). + Must be valid JSON that deserializes to a dictionary with a 'type' field. Keyword Args: - dependencies: The dictionary mapping dependency keys to values. - Keys should be in format ``"."`` or ``".."``. + dependencies: A nested dictionary mapping type identifiers to their injectable dependencies. + See :meth:`from_dict` for detailed structure and examples of the three + injection patterns (simple, dict parameter, and instance-specific). Returns: - New instance of the class. + New instance of the class with any specified dependencies injected. + + Raises: + json.JSONDecodeError: If the JSON string is malformed. + ValueError: If the parsed data doesn't contain a valid 'type' field. """ data = json.loads(value) return cls.from_dict(data, dependencies=dependencies) @classmethod - def _get_type_identifier(cls) -> str: + def _get_type_identifier(cls, value: Mapping[str, Any] | None = None) -> str: """Get the type identifier for this class. - Returns the value of the ``type`` class variable if present, - otherwise returns a snake_cased version of the class name. + The type identifier is used in serialized data to enable proper deserialization. + It follows a priority order to determine the identifier: + + 1. If ``value`` contains a 'type' field, return that value (for ``from_dict``) + 2. If the class has a ``type`` attribute, use that value (instance-level) + 3. If the class has a ``TYPE`` attribute, use that value (class-level constant) + 4. Otherwise, convert the class name to snake_case as fallback + + Args: + value: Optional mapping containing serialized data that may have a 'type' field. Returns: - Type identifier string. + Type identifier string used for serialization and dependency injection mapping. """ + # for from_dict + if value and (type_ := value.get("type")) and isinstance(type_, str): + return type_ # type:ignore[no-any-return] + # for todict when defined per instance if (type_ := getattr(cls, "type", None)) and isinstance(type_, str): return type_ # type:ignore[no-any-return] - + # for both when defined on class. + if (type_ := getattr(cls, "TYPE", None)) and isinstance(type_, str): + return type_ # type:ignore[no-any-return] + # Fallback and default # Convert class name to snake_case return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower() diff --git a/python/packages/core/agent_framework/_threads.py b/python/packages/core/agent_framework/_threads.py index ff8cffc39f8..f7603a7c3cd 100644 --- a/python/packages/core/agent_framework/_threads.py +++ b/python/packages/core/agent_framework/_threads.py @@ -1,11 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. -from collections.abc import Sequence +from collections.abc import MutableMapping, Sequence from typing import Any, Protocol, TypeVar -from pydantic import BaseModel, ConfigDict, model_validator - from ._memory import AggregateContextProvider +from ._serialization import SerializationMixin from ._types import ChatMessage from .exceptions import AgentThreadException @@ -73,7 +72,9 @@ async def add_messages(self, messages: Sequence[ChatMessage]) -> None: ... @classmethod - async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "ChatMessageStoreProtocol": + async def deserialize( + cls, serialized_store_state: MutableMapping[str, Any], **kwargs: Any + ) -> "ChatMessageStoreProtocol": """Creates a new instance of the store from previously serialized state. This method, together with ``serialize()`` can be used to save and load messages from a persistent store @@ -90,7 +91,7 @@ async def deserialize(cls, serialized_store_state: Any, **kwargs: Any) -> "ChatM """ ... - async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: + async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None: """Update the current ChatMessageStore instance from serialized state data. Args: @@ -101,7 +102,7 @@ async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> """ ... - async def serialize(self, **kwargs: Any) -> Any: + async def serialize(self, **kwargs: Any) -> dict[str, Any]: """Serializes the current object's state. This method, together with ``deserialize()`` can be used to save and load messages from a persistent store @@ -116,40 +117,66 @@ async def serialize(self, **kwargs: Any) -> Any: ... -class ChatMessageStoreState(BaseModel): +class ChatMessageStoreState(SerializationMixin): """State model for serializing and deserializing chat message store data. Attributes: messages: List of chat messages stored in the message store. """ - messages: list[ChatMessage] - - model_config = ConfigDict(arbitrary_types_allowed=True) - + def __init__( + self, + messages: Sequence[ChatMessage] | Sequence[MutableMapping[str, Any]] | None = None, + **kwargs: Any, + ) -> None: + """Create the store state. -class AgentThreadState(BaseModel): - """State model for serializing and deserializing thread information. + Args: + messages: a list of messages or a list of the dict representation of messages. - Attributes: - service_thread_id: Optional ID of the thread managed by the agent service. - chat_message_store_state: Optional serialized state of the chat message store. - """ + Keyword Args: + **kwargs: not used for this, but might be used by subclasses. - service_thread_id: str | None = None - chat_message_store_state: ChatMessageStoreState | None = None + """ + if not messages: + self.messages: list[ChatMessage] = [] + if not isinstance(messages, list): + raise TypeError("Messages should be a list") + new_messages: list[ChatMessage] = [] + for msg in messages: + if isinstance(msg, ChatMessage): + new_messages.append(msg) + else: + new_messages.append(ChatMessage.from_dict(msg)) + self.messages = new_messages + + +class AgentThreadState(SerializationMixin): + """State model for serializing and deserializing thread information.""" - model_config = ConfigDict(arbitrary_types_allowed=True) + def __init__( + self, + *, + service_thread_id: str | None = None, + chat_message_store_state: ChatMessageStoreState | MutableMapping[str, Any] | None = None, + ) -> None: + """Create a AgentThread state. - @model_validator(mode="before") - def validate_only_one(cls, values: dict[str, Any]) -> dict[str, Any]: - if ( - isinstance(values, dict) - and values.get("service_thread_id") is not None - and values.get("chat_message_store_state") is not None - ): - raise AgentThreadException("Only one of service_thread_id or chat_message_store_state may be set.") - return values + Keyword Args: + service_thread_id: Optional ID of the thread managed by the agent service. + chat_message_store_state: Optional serialized state of the chat message store. + """ + if service_thread_id is not None and chat_message_store_state is not None: + raise AgentThreadException("A thread cannot have both a service_thread_id and a chat_message_store.") + self.service_thread_id = service_thread_id + self.chat_message_store_state: ChatMessageStoreState | None = None + if chat_message_store_state is not None: + if isinstance(chat_message_store_state, dict): + self.chat_message_store_state = ChatMessageStoreState.from_dict(chat_message_store_state) + elif isinstance(chat_message_store_state, ChatMessageStoreState): + self.chat_message_store_state = chat_message_store_state + else: + raise TypeError("Could not parse ChatMessageStoreState.") TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore") @@ -213,7 +240,7 @@ async def list_messages(self) -> list[ChatMessage]: @classmethod async def deserialize( - cls: type[TChatMessageStore], serialized_store_state: Any, **kwargs: Any + cls: type[TChatMessageStore], serialized_store_state: MutableMapping[str, Any], **kwargs: Any ) -> TChatMessageStore: """Create a new ChatMessageStore instance from serialized state data. @@ -226,12 +253,12 @@ async def deserialize( Returns: A new ChatMessageStore instance populated with messages from the serialized state. """ - state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs) + state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) if state.messages: return cls(messages=state.messages) return cls() - async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> None: + async def update_from_state(self, serialized_store_state: MutableMapping[str, Any], **kwargs: Any) -> None: """Update the current ChatMessageStore instance from serialized state data. Args: @@ -242,11 +269,11 @@ async def update_from_state(self, serialized_store_state: Any, **kwargs: Any) -> """ if not serialized_store_state: return - state = ChatMessageStoreState.model_validate(serialized_store_state, **kwargs) + state = ChatMessageStoreState.from_dict(serialized_store_state, **kwargs) if state.messages: self.messages = state.messages - async def serialize(self, **kwargs: Any) -> Any: + async def serialize(self, **kwargs: Any) -> dict[str, Any]: """Serialize the current store state for persistence. Keyword Args: @@ -256,7 +283,7 @@ async def serialize(self, **kwargs: Any) -> Any: Serialized state data that can be used with deserialize_state. """ state = ChatMessageStoreState(messages=self.messages) - return state.model_dump(**kwargs) + return state.to_dict() TAgentThread = TypeVar("TAgentThread", bound="AgentThread") @@ -403,12 +430,12 @@ async def serialize(self, **kwargs: Any) -> dict[str, Any]: state = AgentThreadState( service_thread_id=self._service_thread_id, chat_message_store_state=chat_message_store_state ) - return state.model_dump() + return state.to_dict(exclude_none=False) @classmethod async def deserialize( cls: type[TAgentThread], - serialized_thread_state: dict[str, Any], + serialized_thread_state: MutableMapping[str, Any], *, message_store: ChatMessageStoreProtocol | None = None, **kwargs: Any, @@ -426,7 +453,7 @@ async def deserialize( Returns: A new AgentThread instance with properties set from the serialized state. """ - state = AgentThreadState.model_validate(serialized_thread_state) + state = AgentThreadState.from_dict(serialized_thread_state) if state.service_thread_id is not None: return cls(service_thread_id=state.service_thread_id) @@ -437,19 +464,19 @@ async def deserialize( if message_store is not None: try: - await message_store.update_from_state(state.chat_message_store_state, **kwargs) + await message_store.add_messages(state.chat_message_store_state.messages, **kwargs) except Exception as ex: raise AgentThreadException("Failed to deserialize the provided message store.") from ex return cls(message_store=message_store) try: - message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs) + message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs) except Exception as ex: raise AgentThreadException("Failed to deserialize the message store.") from ex return cls(message_store=message_store) async def update_from_thread_state( self, - serialized_thread_state: dict[str, Any], + serialized_thread_state: MutableMapping[str, Any], **kwargs: Any, ) -> None: """Deserializes the state from a dictionary into the thread properties. @@ -460,7 +487,7 @@ async def update_from_thread_state( Keyword Args: **kwargs: Additional arguments for deserialization. """ - state = AgentThreadState.model_validate(serialized_thread_state) + state = AgentThreadState.from_dict(serialized_thread_state) if state.service_thread_id is not None: self.service_thread_id = state.service_thread_id @@ -470,8 +497,8 @@ async def update_from_thread_state( if state.chat_message_store_state is None: return if self.message_store is not None: - await self.message_store.update_from_state(state.chat_message_store_state, **kwargs) + await self.message_store.add_messages(state.chat_message_store_state.messages, **kwargs) # If we don't have a chat message store yet, create an in-memory one. return # Create the message store from the default. - self.message_store = await ChatMessageStore.deserialize(state.chat_message_store_state, **kwargs) # type: ignore + self.message_store = ChatMessageStore(messages=state.chat_message_store_state.messages, **kwargs) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a0b3ed69856..22f3dee18aa 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -4,7 +4,7 @@ import inspect import json import sys -from collections.abc import AsyncIterable, Awaitable, Callable, Collection, MutableMapping, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable, Collection, Mapping, MutableMapping, Sequence from functools import wraps from time import perf_counter, time_ns from typing import ( @@ -17,6 +17,7 @@ Literal, Protocol, TypeVar, + cast, get_args, get_origin, runtime_checkable, @@ -24,6 +25,7 @@ from opentelemetry.metrics import Histogram from pydantic import AnyUrl, BaseModel, Field, ValidationError, create_model +from pydantic.fields import FieldInfo from ._logging import get_logger from ._serialization import SerializationMixin @@ -49,9 +51,15 @@ ) if sys.version_info >= (3, 12): - from typing import TypedDict # pragma: no cover + from typing import ( + TypedDict, # pragma: no cover + override, # type: ignore # pragma: no cover + ) else: - from typing_extensions import TypedDict # pragma: no cover + from typing_extensions import ( + TypedDict, # pragma: no cover + override, # type: ignore[import] # pragma: no cover + ) if sys.version_info >= (3, 11): from typing import overload # pragma: no cover @@ -540,6 +548,9 @@ def _default_histogram() -> Histogram: ) +TClass = TypeVar("TClass", bound="SerializationMixin") + + class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): """A tool that wraps a Python function to make it callable by AI models. @@ -593,7 +604,7 @@ def __init__( approval_mode: Literal["always_require", "never_require"] | None = None, additional_properties: dict[str, Any] | None = None, func: Callable[..., Awaitable[ReturnT] | ReturnT], - input_model: type[ArgsT], + input_model: type[ArgsT] | Mapping[str, Any] | None = None, **kwargs: Any, ) -> None: """Initialize the AIFunction. @@ -606,6 +617,8 @@ def __init__( additional_properties: Additional properties to set on the function. func: The function to wrap. input_model: The Pydantic model that defines the input parameters for the function. + This can also be a JSON schema dictionary. + If not provided, it will be inferred from the function signature. **kwargs: Additional keyword arguments. """ super().__init__( @@ -615,9 +628,19 @@ def __init__( **kwargs, ) self.func = func - self.input_model = input_model + self.input_model = self._resolve_input_model(input_model) self.approval_mode = approval_mode or "never_require" self._invocation_duration_histogram = _default_histogram() + self.type: Literal["ai_function"] = "ai_function" + + def _resolve_input_model(self, input_model: type[ArgsT] | Mapping[str, Any] | None) -> type[ArgsT]: + if input_model: + if inspect.isclass(input_model) and issubclass(input_model, BaseModel): + return input_model + if isinstance(input_model, Mapping): + return cast(type[ArgsT], _create_model_from_json_schema(self.name, input_model)) + raise TypeError("input_model must be a Pydantic BaseModel subclass or a JSON schema dict.") + return cast(type[ArgsT], _create_input_model_from_func(self.func, self.name)) def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]: """Call the wrapped function with the provided arguments.""" @@ -725,6 +748,14 @@ def to_json_schema_spec(self) -> dict[str, Any]: }, } + @override + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: + as_dict = super().to_dict(exclude=exclude, exclude_none=exclude_none) + if (exclude and "input_model" in exclude) or not self.input_model: + return as_dict + as_dict["input_model"] = self.input_model.model_json_schema() + return as_dict + def _tools_to_dict( tools: ( @@ -802,6 +833,73 @@ def _parse_annotation(annotation: Any) -> Any: return annotation +def _create_input_model_from_func(func: Callable[..., Any], tool_name: str) -> type[BaseModel]: + """Create a Pydantic model from a function's signature.""" + sig = inspect.signature(func) + fields = { + pname: ( + _parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str, + param.default if param.default is not inspect.Parameter.empty else ..., + ) + for pname, param in sig.parameters.items() + if pname not in {"self", "cls"} + } + return create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload, no-any-return] + + +# Map JSON Schema types to Pydantic types +TYPE_MAPPING = { + "string": str, + "integer": int, + "number": float, + "boolean": bool, + "array": list, + "object": dict, + "null": type(None), +} + + +def _create_model_from_json_schema(tool_name: str, schema_json: Mapping[str, Any]) -> type[BaseModel]: + """Creates a Pydantic model from a given JSON Schema. + + Args: + tool_name: The name of the model to be created. + schema_json: The JSON Schema definition. + + Returns: + The dynamically created Pydantic model class. + """ + # Validate that 'properties' exists and is a dict + if "properties" not in schema_json or not isinstance(schema_json["properties"], dict): + raise ValueError( + f"JSON schema for tool '{tool_name}' must contain a 'properties' key of type dict. " + f"Got: {schema_json.get('properties', None)}" + ) + # Extract field definitions with type annotations + field_definitions: dict[str, tuple[type, FieldInfo]] = {} + for field_name, field_schema in schema_json["properties"].items(): + field_args: dict[str, Any] = {} + if (field_description := field_schema.get("description", None)) is not None: + field_args["description"] = field_description + if (field_default := field_schema.get("default", None)) is not None: + field_args["default"] = field_default + field_type = field_schema.get("type", None) + if field_type is None: + raise ValueError( + f"Missing 'type' for field '{field_name}' in JSON schema. " + f"Got: {field_schema}, Supported types: {list(TYPE_MAPPING.keys())}" + ) + python_type = TYPE_MAPPING.get(field_type) + if python_type is None: + raise ValueError( + f"Unsupported type '{field_type}' for field '{field_name}' in JSON schema. " + f"Got: {field_schema}, Supported types: {list(TYPE_MAPPING.keys())}" + ) + field_definitions[field_name] = (python_type, Field(**field_args)) + + return create_model(f"{tool_name}_input", **field_definitions) # type: ignore[call-overload, no-any-return] + + @overload def ai_function( func: Callable[..., ReturnT | Awaitable[ReturnT]], @@ -895,26 +993,12 @@ def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[A def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]: tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment] tool_desc: str = description or (f.__doc__ or "") - sig = inspect.signature(f) - fields = { - pname: ( - _parse_annotation(param.annotation) if param.annotation is not inspect.Parameter.empty else str, - param.default if param.default is not inspect.Parameter.empty else ..., - ) - for pname, param in sig.parameters.items() - if pname not in {"self", "cls"} - } - input_model: Any = create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload] - if not issubclass(input_model, BaseModel): - raise TypeError(f"Input model for {tool_name} must be a subclass of BaseModel, got {input_model}") - return AIFunction[Any, ReturnT]( name=tool_name, description=tool_desc, approval_mode=approval_mode, additional_properties=additional_properties or {}, func=f, - input_model=input_model, ) return wrapper(func) @@ -1142,16 +1226,17 @@ def _extract_tools(kwargs: dict[str, Any]) -> Any: return tools -def _collect_approval_todos( +def _collect_approval_responses( messages: "list[ChatMessage]", ) -> dict[str, "FunctionApprovalResponseContent"]: - """Collect approved function calls from messages.""" + """Collect approval responses (both approved and rejected) from messages.""" from ._types import ChatMessage, FunctionApprovalResponseContent fcc_todo: dict[str, FunctionApprovalResponseContent] = {} for msg in messages: for content in msg.contents if isinstance(msg, ChatMessage) else []: - if isinstance(content, FunctionApprovalResponseContent) and content.approved: + # Collect BOTH approved and rejected responses + if isinstance(content, FunctionApprovalResponseContent): fcc_todo[content.id] = content return fcc_todo @@ -1162,26 +1247,52 @@ def _replace_approval_contents_with_results( approved_function_results: "list[Contents]", ) -> None: """Replace approval request/response contents with function call/result contents in-place.""" - from ._types import FunctionApprovalRequestContent, FunctionApprovalResponseContent, FunctionResultContent + from ._types import ( + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, + FunctionCallContent, + FunctionResultContent, + Role, + ) result_idx = 0 for msg in messages: + # First pass - collect existing function call IDs to avoid duplicates + existing_call_ids = { + content.call_id for content in msg.contents if isinstance(content, FunctionCallContent) and content.call_id + } + + # Track approval requests that should be removed (duplicates) + contents_to_remove = [] + for content_idx, content in enumerate(msg.contents): if isinstance(content, FunctionApprovalRequestContent): - # put back the function call content - msg.contents[content_idx] = content.function_call - if isinstance(content, FunctionApprovalResponseContent): + # Don't add the function call if it already exists (would create duplicate) + if content.function_call.call_id in existing_call_ids: + # Just mark for removal - the function call already exists + contents_to_remove.append(content_idx) + else: + # Put back the function call content only if it doesn't exist + msg.contents[content_idx] = content.function_call + elif isinstance(content, FunctionApprovalResponseContent): if content.approved and content.id in fcc_todo: # Replace with the corresponding result if result_idx < len(approved_function_results): msg.contents[content_idx] = approved_function_results[result_idx] result_idx += 1 + msg.role = Role.TOOL else: # Create a "not approved" result for rejected calls + # Use function_call.call_id (the function's ID), not content.id (approval's ID) msg.contents[content_idx] = FunctionResultContent( - call_id=content.id, + call_id=content.function_call.call_id, result="Error: Tool call invocation was rejected by user.", ) + msg.role = Role.TOOL + + # Remove approval requests that were duplicates (in reverse order to preserve indices) + for idx in reversed(contents_to_remove): + msg.contents.pop(idx) def _handle_function_calls_response( @@ -1234,16 +1345,20 @@ async def function_invocation_wrapper( response: "ChatResponse | None" = None fcc_messages: "list[ChatMessage]" = [] for attempt_idx in range(instance_max_iterations): - fcc_todo = _collect_approval_todos(prepped_messages) + fcc_todo = _collect_approval_responses(prepped_messages) if fcc_todo: tools = _extract_tools(kwargs) - approved_function_results: list[Contents] = await _execute_function_calls( - custom_args=kwargs, - attempt_idx=attempt_idx, - function_calls=list(fcc_todo.values()), - tools=tools, # type: ignore - middleware_pipeline=stored_middleware_pipeline, - ) + # Only execute APPROVED function calls, not rejected ones + approved_responses = [resp for resp in fcc_todo.values() if resp.approved] + approved_function_results: list[Contents] = [] + if approved_responses: + approved_function_results = await _execute_function_calls( + custom_args=kwargs, + attempt_idx=attempt_idx, + function_calls=approved_responses, + tools=tools, # type: ignore + middleware_pipeline=stored_middleware_pipeline, + ) _replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results) response = await func(self, messages=prepped_messages, **kwargs) @@ -1273,6 +1388,21 @@ async def function_invocation_wrapper( tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, ) + + # Check if we have approval requests in the results + if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): + # Add approval requests to the existing assistant message (with tool_calls) + # instead of creating a separate tool message + from ._types import Role + + if response.messages and response.messages[0].role == Role.ASSISTANT: + response.messages[0].contents.extend(function_call_results) + else: + # Fallback: create new assistant message (shouldn't normally happen) + result_message = ChatMessage(role="assistant", contents=function_call_results) + response.messages.append(result_message) + return response + # add a single ChatMessage to the response with the results result_message = ChatMessage(role="tool", contents=function_call_results) response.messages.append(result_message) @@ -1283,9 +1413,6 @@ async def function_invocation_wrapper( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - # and add them as additional context to the messages - if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): - return response if getattr(kwargs.get("chat_options"), "store", False): prepped_messages.clear() prepped_messages.append(result_message) @@ -1365,16 +1492,20 @@ async def streaming_function_invocation_wrapper( prepped_messages = prepare_messages(messages) fcc_messages: "list[ChatMessage]" = [] for attempt_idx in range(instance_max_iterations): - fcc_todo = _collect_approval_todos(prepped_messages) + fcc_todo = _collect_approval_responses(prepped_messages) if fcc_todo: tools = _extract_tools(kwargs) - approved_function_results: list[Contents] = await _execute_function_calls( - custom_args=kwargs, - attempt_idx=attempt_idx, - function_calls=list(fcc_todo.values()), - tools=tools, # type: ignore - middleware_pipeline=stored_middleware_pipeline, - ) + # Only execute APPROVED function calls, not rejected ones + approved_responses = [resp for resp in fcc_todo.values() if resp.approved] + approved_function_results: list[Contents] = [] + if approved_responses: + approved_function_results = await _execute_function_calls( + custom_args=kwargs, + attempt_idx=attempt_idx, + function_calls=approved_responses, + tools=tools, # type: ignore + middleware_pipeline=stored_middleware_pipeline, + ) _replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results) all_updates: list["ChatResponseUpdate"] = [] @@ -1427,6 +1558,24 @@ async def streaming_function_invocation_wrapper( tools=tools, # type: ignore middleware_pipeline=stored_middleware_pipeline, ) + + # Check if we have approval requests in the results + if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): + # Add approval requests to the existing assistant message (with tool_calls) + # instead of creating a separate tool message + from ._types import Role + + if response.messages and response.messages[0].role == Role.ASSISTANT: + response.messages[0].contents.extend(function_call_results) + # Yield the approval requests as part of the assistant message + yield ChatResponseUpdate(contents=function_call_results, role="assistant") + else: + # Fallback: create new assistant message (shouldn't normally happen) + result_message = ChatMessage(role="assistant", contents=function_call_results) + yield ChatResponseUpdate(contents=function_call_results, role="assistant") + response.messages.append(result_message) + return + # add a single ChatMessage to the response with the results result_message = ChatMessage(role="tool", contents=function_call_results) yield ChatResponseUpdate(contents=function_call_results, role="tool") @@ -1438,9 +1587,6 @@ async def streaming_function_invocation_wrapper( # this runs in every but the first run # we need to keep track of all function call messages fcc_messages.extend(response.messages) - # and add them as additional context to the messages - if any(isinstance(fccr, FunctionApprovalRequestContent) for fccr in function_call_results): - return if getattr(kwargs.get("chat_options"), "store", False): prepped_messages.clear() prepped_messages.append(result_message) diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index f9e292465ad..94950e19487 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -12,6 +12,7 @@ InMemoryCheckpointStorage, WorkflowCheckpoint, ) +from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary from ._concurrent import ConcurrentBuilder from ._const import ( DEFAULT_MAX_ITERATIONS, @@ -48,9 +49,6 @@ ) from ._executor import ( Executor, - RequestInfoExecutor, - RequestInfoMessage, - RequestResponse, handler, ) from ._function_executor import FunctionExecutor, executor @@ -76,6 +74,12 @@ MagenticStartMessage, StandardMagenticManager, ) +from ._request_info_executor import ( + PendingRequestDetails, + RequestInfoExecutor, + RequestInfoMessage, + RequestResponse, +) from ._runner import Runner from ._runner_context import ( InProcRunnerContext, @@ -144,6 +148,7 @@ "MagenticResponseMessage", "MagenticStartMessage", "Message", + "PendingRequestDetails", "RequestInfoEvent", "RequestInfoExecutor", "RequestInfoMessage", @@ -163,6 +168,7 @@ "WorkflowAgent", "WorkflowBuilder", "WorkflowCheckpoint", + "WorkflowCheckpointSummary", "WorkflowContext", "WorkflowErrorDetails", "WorkflowEvent", @@ -179,6 +185,7 @@ "WorkflowViz", "create_edge_runner", "executor", + "get_checkpoint_summary", "handler", "validate_workflow_graph", ] diff --git a/python/packages/core/agent_framework/_workflows/__init__.pyi b/python/packages/core/agent_framework/_workflows/__init__.pyi index 5b0dcc799d2..d98829c56da 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.pyi +++ b/python/packages/core/agent_framework/_workflows/__init__.pyi @@ -12,6 +12,7 @@ from ._checkpoint import ( InMemoryCheckpointStorage, WorkflowCheckpoint, ) +from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary from ._concurrent import ConcurrentBuilder from ._const import DEFAULT_MAX_ITERATIONS from ._edge import ( @@ -46,9 +47,6 @@ from ._events import ( ) from ._executor import ( Executor, - RequestInfoExecutor, - RequestInfoMessage, - RequestResponse, handler, ) from ._function_executor import FunctionExecutor, executor @@ -74,6 +72,12 @@ from ._magentic import ( MagenticStartMessage, StandardMagenticManager, ) +from ._request_info_executor import ( + PendingRequestDetails, + RequestInfoExecutor, + RequestInfoMessage, + RequestResponse, +) from ._runner import Runner from ._runner_context import ( InProcRunnerContext, @@ -142,6 +146,7 @@ __all__ = [ "MagenticResponseMessage", "MagenticStartMessage", "Message", + "PendingRequestDetails", "RequestInfoEvent", "RequestInfoExecutor", "RequestInfoMessage", @@ -161,6 +166,7 @@ __all__ = [ "WorkflowAgent", "WorkflowBuilder", "WorkflowCheckpoint", + "WorkflowCheckpointSummary", "WorkflowContext", "WorkflowErrorDetails", "WorkflowEvent", @@ -177,6 +183,7 @@ __all__ = [ "WorkflowViz", "create_edge_runner", "executor", + "get_checkpoint_summary", "handler", "validate_workflow_graph", ] diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 4d40eb6168d..b92c845a4d8 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -104,9 +104,6 @@ async def _run_agent_and_emit(self, ctx: WorkflowContext[AgentExecutorResponse, self._cache, thread=self._agent_thread, ): - if not update.text: - # Skip empty updates (no textual or structural content) - continue updates.append(update) await ctx.add_event(AgentRunUpdateEvent(self.id, update)) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py b/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py new file mode 100644 index 00000000000..e42e05dd916 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py @@ -0,0 +1,191 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from textwrap import shorten +from typing import Any + +from ._checkpoint import WorkflowCheckpoint +from ._request_info_executor import PendingRequestDetails, RequestInfoMessage, RequestResponse +from ._runner_context import _decode_checkpoint_value # type: ignore + +logger = logging.getLogger(__name__) + + +@dataclass +class WorkflowCheckpointSummary: + """Human-readable summary of a workflow checkpoint.""" + + checkpoint_id: str + iteration_count: int + targets: list[str] + executor_ids: list[str] + status: str + draft_preview: str | None + pending_requests: list[PendingRequestDetails] + + +def get_checkpoint_summary( + checkpoint: WorkflowCheckpoint, + *, + request_executor_ids: Iterable[str] | None = None, + preview_width: int = 70, +) -> WorkflowCheckpointSummary: + targets = sorted(checkpoint.messages.keys()) + executor_ids = sorted(checkpoint.executor_states.keys()) + pending = _pending_requests_from_checkpoint(checkpoint, request_executor_ids=request_executor_ids) + + draft_preview: str | None = None + for entry in pending: + if entry.draft: + draft_preview = shorten(entry.draft, width=preview_width, placeholder="…") + break + + status = "idle" + if pending: + status = "awaiting request response" + elif not checkpoint.messages and "finalise" in executor_ids: + status = "completed" + elif checkpoint.messages: + status = "awaiting next superstep" + elif request_executor_ids is not None and any(tid in targets for tid in request_executor_ids): + status = "awaiting request delivery" + + return WorkflowCheckpointSummary( + checkpoint_id=checkpoint.checkpoint_id, + iteration_count=checkpoint.iteration_count, + targets=targets, + executor_ids=executor_ids, + status=status, + draft_preview=draft_preview, + pending_requests=pending, + ) + + +def _pending_requests_from_checkpoint( + checkpoint: WorkflowCheckpoint, + *, + request_executor_ids: Iterable[str] | None = None, +) -> list[PendingRequestDetails]: + executor_filter: set[str] | None = None + if request_executor_ids is not None: + executor_filter = {str(value) for value in request_executor_ids} + + pending: dict[str, PendingRequestDetails] = {} + + for state in checkpoint.executor_states.values(): + if not isinstance(state, Mapping): + continue + inner = state.get("pending_requests") + if isinstance(inner, Mapping): + for request_id, snapshot in inner.items(): # type: ignore[attr-defined] + _merge_snapshot(pending, str(request_id), snapshot) # type: ignore[arg-type] + + for source_id, message_list in checkpoint.messages.items(): + if executor_filter is not None and source_id not in executor_filter: + continue + if not isinstance(message_list, list): + continue + for message in message_list: + if not isinstance(message, Mapping): + continue + payload = _decode_checkpoint_value(message.get("data")) + _merge_message_payload(pending, payload, message) + + return list(pending.values()) + + +def _merge_snapshot(pending: dict[str, PendingRequestDetails], request_id: str, snapshot: Any) -> None: + if not request_id or not isinstance(snapshot, Mapping): + return + + details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) + + _apply_update( + details, + prompt=snapshot.get("prompt"), # type: ignore[attr-defined] + draft=snapshot.get("draft"), # type: ignore[attr-defined] + iteration=snapshot.get("iteration"), # type: ignore[attr-defined] + source_executor_id=snapshot.get("source_executor_id"), # type: ignore[attr-defined] + ) + + extra = snapshot.get("details") # type: ignore[attr-defined] + if isinstance(extra, Mapping): + _apply_update( + details, + prompt=extra.get("prompt"), # type: ignore[attr-defined] + draft=extra.get("draft"), # type: ignore[attr-defined] + iteration=extra.get("iteration"), # type: ignore[attr-defined] + ) + + +def _merge_message_payload( + pending: dict[str, PendingRequestDetails], + payload: Any, + raw_message: Mapping[str, Any], +) -> None: + if isinstance(payload, RequestResponse): + request_id = payload.request_id or _get_field(payload.original_request, "request_id") # type: ignore[arg-type] + if not request_id: + return + details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) + _apply_update( + details, + prompt=_get_field(payload.original_request, "prompt"), # type: ignore[arg-type] + draft=_get_field(payload.original_request, "draft"), # type: ignore[arg-type] + iteration=_get_field(payload.original_request, "iteration"), # type: ignore[arg-type] + source_executor_id=raw_message.get("source_id"), + original_request=payload.original_request, # type: ignore[arg-type] + ) + elif isinstance(payload, RequestInfoMessage): + request_id = getattr(payload, "request_id", None) + if not request_id: + return + details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) + _apply_update( + details, + prompt=getattr(payload, "prompt", None), + draft=getattr(payload, "draft", None), + iteration=getattr(payload, "iteration", None), + source_executor_id=raw_message.get("source_id"), + original_request=payload, + ) + + +def _apply_update( + details: PendingRequestDetails, + *, + prompt: Any = None, + draft: Any = None, + iteration: Any = None, + source_executor_id: Any = None, + original_request: Any = None, +) -> None: + if prompt and not details.prompt: + details.prompt = str(prompt) + if draft and not details.draft: + details.draft = str(draft) + if iteration is not None and details.iteration is None: + coerced = _coerce_int(iteration) + if coerced is not None: + details.iteration = coerced + if source_executor_id and not details.source_executor_id: + details.source_executor_id = str(source_executor_id) + if original_request is not None and details.original_request is None: + details.original_request = original_request + + +def _get_field(obj: Any, key: str) -> Any: + if obj is None: + return None + if isinstance(obj, Mapping): + return obj.get(key) # type: ignore[attr-defined,return-value] + return getattr(obj, key, None) + + +def _coerce_int(value: Any) -> int | None: + try: + return int(value) + except (TypeError, ValueError): + return None diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index 90a2f1f1744..58e699e2b43 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -11,7 +11,7 @@ from agent_framework import AgentRunResponse, AgentRunResponseUpdate if TYPE_CHECKING: - from ._executor import RequestInfoMessage + from ._request_info_executor import RequestInfoMessage class WorkflowEventSource(str, Enum): diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 80dd30e4024..1f822e870a0 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -2,61 +2,29 @@ import contextlib import functools -import importlib import inspect -import json import logging -import uuid -from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence -from dataclasses import asdict, dataclass, field, fields, is_dataclass -from textwrap import shorten -from typing import Any, ClassVar, Generic, TypeVar, cast +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar from ..observability import create_processing_span -from ._checkpoint import WorkflowCheckpoint from ._events import ( ExecutorCompletedEvent, ExecutorFailedEvent, ExecutorInvokedEvent, - RequestInfoEvent, WorkflowErrorDetails, _framework_event_origin, # type: ignore[reportPrivateUsage] ) from ._model_utils import DictConvertible -from ._runner_context import Message, RunnerContext, _decode_checkpoint_value # type: ignore +from ._runner_context import Message, RunnerContext # type: ignore from ._shared_state import SharedState from ._typing_utils import is_instance_of from ._workflow_context import WorkflowContext, validate_function_signature logger = logging.getLogger(__name__) -# region Executor - - -@dataclass -class PendingRequestDetails: - """Lightweight information about a pending request captured in a checkpoint.""" - - request_id: str - prompt: str | None = None - draft: str | None = None - iteration: int | None = None - source_executor_id: str | None = None - original_request: "RequestInfoMessage | dict[str, Any] | None" = None - - -@dataclass -class WorkflowCheckpointSummary: - """Human-readable summary of a workflow checkpoint.""" - - checkpoint_id: str - iteration_count: int - targets: list[str] - executor_states: list[str] - status: str - draft_preview: str | None - pending_requests: list[PendingRequestDetails] +# region Executor class Executor(DictConvertible): """Base class for all workflow executors that process messages and perform computations. @@ -542,802 +510,3 @@ async def wrapper(self: ExecutorT, message: Any, ctx: ContextT) -> Any: # endregion: Handler Decorator - - -# region Request/Response Types -@dataclass -class RequestInfoMessage: - """Base class for all request messages in workflows. - - Any message that should be routed to the RequestInfoExecutor for external - handling must inherit from this class. This ensures type safety and makes - the request/response pattern explicit. - """ - - request_id: str = field(default_factory=lambda: str(uuid.uuid4())) - """Unique identifier for correlating requests and responses.""" - - source_executor_id: str | None = None - """ID of the executor expecting a response to this request. - May differ from the executor that sent the request if intercepted and forwarded.""" - - -TRequest = TypeVar("TRequest", bound="RequestInfoMessage") -TResponse = TypeVar("TResponse") - - -@dataclass -class RequestResponse(Generic[TRequest, TResponse]): - """Response type for request/response correlation in workflows. - - This type is used by RequestInfoExecutor to create correlated responses - that include the original request context for proper message routing. - """ - - data: TResponse - """The response data returned from handling the request.""" - - original_request: TRequest - """The original request that this response corresponds to.""" - - request_id: str - """The ID of the original request.""" - - -# endregion: Request/Response Types - - -# region Request Info Executor -class RequestInfoExecutor(Executor): - """Built-in executor that handles request/response patterns in workflows. - - This executor acts as a gateway for external information requests. When it receives - a request message, it saves the request details and emits a RequestInfoEvent. When - a response is provided externally, it emits the response as a message. - """ - - _PENDING_SHARED_STATE_KEY: ClassVar[str] = "_af_pending_request_info" - - def __init__(self, id: str): - """Initialize the RequestInfoExecutor with a unique ID. - - Args: - id: Unique ID for this RequestInfoExecutor. - """ - super().__init__(id=id) - self._request_events: dict[str, RequestInfoEvent] = {} - - @handler - async def run(self, message: RequestInfoMessage, ctx: WorkflowContext) -> None: - """Run the RequestInfoExecutor with the given message.""" - # Use source_executor_id from message if available, otherwise fall back to context - source_executor_id = message.source_executor_id or ctx.get_source_executor_id() - - event = RequestInfoEvent( - request_id=message.request_id, - source_executor_id=source_executor_id, - request_type=type(message), - request_data=message, - ) - self._request_events[message.request_id] = event - await self._record_pending_request_snapshot(message, source_executor_id, ctx) - await ctx.add_event(event) - - async def handle_response( - self, - response_data: Any, - request_id: str, - ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any]], - ) -> None: - """Handle a response to a request. - - Args: - request_id: The ID of the request to which this response corresponds. - response_data: The data returned in the response. - ctx: The workflow context for sending the response. - """ - event = self._request_events.get(request_id) - if event is None: - event = await self._rehydrate_request_event(request_id, ctx) - if event is None: - raise ValueError(f"No request found with ID: {request_id}") - - self._request_events.pop(request_id, None) - - # Create a correlated response that includes both the response data and original request - if not isinstance(event.data, RequestInfoMessage): - raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}") - correlated_response = RequestResponse(data=response_data, original_request=event.data, request_id=request_id) - await ctx.send_message(correlated_response, target_id=event.source_executor_id) - - await self._clear_pending_request_snapshot(request_id, ctx) - - async def _record_pending_request_snapshot( - self, - request: RequestInfoMessage, - source_executor_id: str, - ctx: WorkflowContext[Any], - ) -> None: - snapshot = self._build_request_snapshot(request, source_executor_id) - - pending = await self._load_pending_request_state(ctx) - pending[request.request_id] = snapshot - await self._persist_pending_request_state(pending, ctx) - await self._write_executor_state(ctx, pending) - - async def _clear_pending_request_snapshot(self, request_id: str, ctx: WorkflowContext[Any]) -> None: - pending = await self._load_pending_request_state(ctx) - if request_id in pending: - pending.pop(request_id, None) - await self._persist_pending_request_state(pending, ctx) - await self._write_executor_state(ctx, pending) - - async def _load_pending_request_state(self, ctx: WorkflowContext[Any]) -> dict[str, Any]: - try: - existing = await ctx.get_shared_state(self._PENDING_SHARED_STATE_KEY) - except KeyError: - return {} - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to read pending request state: {exc}") - return {} - - if not isinstance(existing, dict): - if existing not in (None, {}): - logger.warning( - f"RequestInfoExecutor {self.id} encountered non-dict pending state " - f"({type(existing).__name__}); resetting." - ) - return {} - - return dict(existing) # type: ignore[arg-type] - - async def _persist_pending_request_state(self, pending: dict[str, Any], ctx: WorkflowContext[Any]) -> None: - await self._safe_set_shared_state(ctx, pending) - await self._safe_set_runner_state(ctx, pending) - - async def _safe_set_shared_state(self, ctx: WorkflowContext[Any], pending: dict[str, Any]) -> None: - try: - await ctx.set_shared_state(self._PENDING_SHARED_STATE_KEY, pending) - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to update shared pending state: {exc}") - - async def _safe_set_runner_state(self, ctx: WorkflowContext[Any], pending: dict[str, Any]) -> None: - try: - await ctx.set_state({"pending_requests": pending}) - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to update runner state with pending requests: {exc}") - - def snapshot_state(self) -> dict[str, Any]: - """Serialize pending requests so checkpoint restoration can resume seamlessly.""" - - def _encode_event(event: RequestInfoEvent) -> dict[str, Any]: - request_data = event.data - payload: dict[str, Any] - data_cls = request_data.__class__ if request_data is not None else type(None) - - payload = self._encode_request_payload(request_data, data_cls) - - return { - "source_executor_id": event.source_executor_id, - "request_type": f"{event.request_type.__module__}:{event.request_type.__qualname__}", - "request_data": payload, - } - - return { - "request_events": {rid: _encode_event(event) for rid, event in self._request_events.items()}, - } - - def _encode_request_payload(self, request_data: RequestInfoMessage | None, data_cls: type[Any]) -> dict[str, Any]: - if request_data is None or isinstance(request_data, (str, int, float, bool)): - return { - "kind": "raw", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": request_data, - } - - if is_dataclass(request_data) and not isinstance(request_data, type): - dataclass_instance = cast(Any, request_data) - safe_value = self._make_json_safe(asdict(dataclass_instance)) - return { - "kind": "dataclass", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": safe_value, - } - - to_dict_fn = getattr(request_data, "to_dict", None) - if callable(to_dict_fn): - try: - dumped = to_dict_fn() - except TypeError: - dumped = to_dict_fn() - safe_value = self._make_json_safe(dumped) - return { - "kind": "dict", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": safe_value, - } - - to_json_fn = getattr(request_data, "to_json", None) - if callable(to_json_fn): - try: - dumped = to_json_fn() - except TypeError: - dumped = to_json_fn() - converted = dumped - if isinstance(dumped, (str, bytes, bytearray)): - decoded: str | bytes | bytearray - if isinstance(dumped, (bytes, bytearray)): - try: - decoded = dumped.decode() - except Exception: - decoded = dumped - else: - decoded = dumped - try: - converted = json.loads(decoded) - except Exception: - converted = decoded - safe_value = self._make_json_safe(converted) - return { - "kind": "dict" if isinstance(converted, dict) else "json", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": safe_value, - } - - details = self._serialise_request_details(request_data) - if details is not None: - safe_value = self._make_json_safe(details) - return { - "kind": "raw", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": safe_value, - } - - safe_value = self._make_json_safe(request_data) - return { - "kind": "raw", - "type": f"{data_cls.__module__}:{data_cls.__qualname__}", - "value": safe_value, - } - - def restore_state(self, state: dict[str, Any]) -> None: - """Restore pending request bookkeeping from checkpoint state.""" - self._request_events.clear() - stored_events = state.get("request_events", {}) - - for request_id, payload in stored_events.items(): - request_type_qual = payload.get("request_type", "") - try: - request_type = self._import_qualname(request_type_qual) - except Exception as exc: # pragma: no cover - defensive fallback - logger.debug( - "RequestInfoExecutor %s failed to import %s during restore: %s", - self.id, - request_type_qual, - exc, - ) - request_type = RequestInfoMessage - request_data_meta = payload.get("request_data", {}) - request_data = self._decode_request_data(request_data_meta) - event = RequestInfoEvent( - request_id=request_id, - source_executor_id=payload.get("source_executor_id", ""), - request_type=request_type, - request_data=request_data, - ) - self._request_events[request_id] = event - - @staticmethod - def _import_qualname(qualname: str) -> type[Any]: - module_name, _, type_name = qualname.partition(":") - if not module_name or not type_name: - raise ValueError(f"Invalid qualified name: {qualname}") - module = importlib.import_module(module_name) - attr: Any = module - for part in type_name.split("."): - attr = getattr(attr, part) - if not isinstance(attr, type): - raise TypeError(f"Resolved object is not a type: {qualname}") - return attr - - def _decode_request_data(self, metadata: dict[str, Any]) -> RequestInfoMessage: - kind = metadata.get("kind") - type_name = metadata.get("type", "") - value: Any = metadata.get("value", {}) - if type_name: - try: - imported = self._import_qualname(type_name) - except Exception as exc: # pragma: no cover - defensive fallback - logger.debug( - "RequestInfoExecutor %s failed to import %s during decode: %s", - self.id, - type_name, - exc, - ) - imported = RequestInfoMessage - else: - imported = RequestInfoMessage - target_cls: type[RequestInfoMessage] - if isinstance(imported, type) and issubclass(imported, RequestInfoMessage): - target_cls = imported - else: - target_cls = RequestInfoMessage - - if kind == "dataclass" and isinstance(value, dict): - with contextlib.suppress(TypeError): - return target_cls(**value) # type: ignore[arg-type] - - # Backwards-compat handling for checkpoints that used to store pydantic as "dict" - if kind in {"dict", "pydantic", "json"} and isinstance(value, dict): - from_dict = getattr(target_cls, "from_dict", None) - if callable(from_dict): - with contextlib.suppress(Exception): - return cast(RequestInfoMessage, from_dict(value)) - - if kind == "json" and isinstance(value, str): - from_json = getattr(target_cls, "from_json", None) - if callable(from_json): - with contextlib.suppress(Exception): - return cast(RequestInfoMessage, from_json(value)) - with contextlib.suppress(Exception): - parsed = json.loads(value) - if isinstance(parsed, dict): - return self._decode_request_data({"kind": "dict", "type": type_name, "value": parsed}) - - if isinstance(value, dict): - with contextlib.suppress(TypeError): - return target_cls(**value) # type: ignore[arg-type] - instance = object.__new__(target_cls) - instance.__dict__.update(value) # type: ignore[arg-type] - return instance - - with contextlib.suppress(Exception): - return target_cls() - return RequestInfoMessage() - - async def _write_executor_state(self, ctx: WorkflowContext[Any], pending: dict[str, Any]) -> None: - state = self.snapshot_state() - state["pending_requests"] = pending - try: - await ctx.set_state(state) - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to persist executor state: {exc}") - - def _build_request_snapshot( - self, - request: RequestInfoMessage, - source_executor_id: str, - ) -> dict[str, Any]: - snapshot: dict[str, Any] = { - "request_id": request.request_id, - "source_executor_id": source_executor_id, - "request_type": f"{type(request).__module__}:{type(request).__name__}", - "summary": repr(request), - } - - details = self._serialise_request_details(request) - if details: - snapshot["details"] = details - for key in ("prompt", "draft", "iteration"): - if key in details and key not in snapshot: - snapshot[key] = details[key] - - return snapshot - - def _serialise_request_details(self, request: RequestInfoMessage) -> dict[str, Any] | None: - if is_dataclass(request): - data = self._make_json_safe(asdict(request)) - if isinstance(data, dict): - return cast(dict[str, Any], data) - return None - - to_dict = getattr(request, "to_dict", None) - if callable(to_dict): - try: - dump = self._make_json_safe(to_dict()) - except TypeError: - dump = self._make_json_safe(to_dict()) - if isinstance(dump, dict): - return cast(dict[str, Any], dump) - return None - - to_json = getattr(request, "to_json", None) - if callable(to_json): - try: - raw = to_json() - except TypeError: - raw = to_json() - converted = raw - if isinstance(raw, (str, bytes, bytearray)): - decoded: str | bytes | bytearray - if isinstance(raw, (bytes, bytearray)): - try: - decoded = raw.decode() - except Exception: - decoded = raw - else: - decoded = raw - try: - converted = json.loads(decoded) - except Exception: - converted = decoded - dump = self._make_json_safe(converted) - if isinstance(dump, dict): - return cast(dict[str, Any], dump) - return None - - attrs = getattr(request, "__dict__", None) - if isinstance(attrs, dict): - cleaned = self._make_json_safe(attrs) - if isinstance(cleaned, dict): - return cast(dict[str, Any], cleaned) - - return None - - def _make_json_safe(self, value: Any) -> Any: - if value is None or isinstance(value, (str, int, float, bool)): - return value - if isinstance(value, Mapping): - safe_dict: dict[str, Any] = {} - for key, val in value.items(): # type: ignore[attr-defined] - safe_dict[str(key)] = self._make_json_safe(val) # type: ignore[arg-type] - return safe_dict - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [self._make_json_safe(item) for item in value] # type: ignore[misc] - return repr(value) - - async def has_pending_request(self, request_id: str, ctx: WorkflowContext[Any]) -> bool: - if request_id in self._request_events: - return True - snapshot = await self._get_pending_request_snapshot(request_id, ctx) - return snapshot is not None - - async def _rehydrate_request_event( - self, - request_id: str, - ctx: WorkflowContext[Any], - ) -> RequestInfoEvent | None: - snapshot = await self._get_pending_request_snapshot(request_id, ctx) - if snapshot is None: - return None - - source_executor_id = snapshot.get("source_executor_id") - if not isinstance(source_executor_id, str) or not source_executor_id: - return None - - request = self._construct_request_from_snapshot(snapshot) - if request is None: - return None - - event = RequestInfoEvent( - request_id=request_id, - source_executor_id=source_executor_id, - request_type=type(request), - request_data=request, - ) - self._request_events[request_id] = event - return event - - async def _get_pending_request_snapshot(self, request_id: str, ctx: WorkflowContext[Any]) -> dict[str, Any] | None: - pending = await self._collect_pending_request_snapshots(ctx) - snapshot = pending.get(request_id) - if snapshot is None: - return None - return snapshot - - async def _collect_pending_request_snapshots(self, ctx: WorkflowContext[Any]) -> dict[str, dict[str, Any]]: - combined: dict[str, dict[str, Any]] = {} - - try: - shared_pending = await ctx.get_shared_state(self._PENDING_SHARED_STATE_KEY) - except KeyError: - shared_pending = None - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to read shared pending state during rehydrate: {exc}") - shared_pending = None - - if isinstance(shared_pending, dict): - for key, value in shared_pending.items(): # type: ignore[attr-defined] - if isinstance(key, str) and isinstance(value, dict): - combined[key] = cast(dict[str, Any], value) - - try: - state = await ctx.get_state() - except Exception as exc: # pragma: no cover - transport specific - logger.warning(f"RequestInfoExecutor {self.id} failed to read runner state during rehydrate: {exc}") - state = None - - if isinstance(state, dict): - state_pending = state.get("pending_requests") - if isinstance(state_pending, dict): - for key, value in state_pending.items(): # type: ignore[attr-defined] - if isinstance(key, str) and isinstance(value, dict) and key not in combined: - combined[key] = cast(dict[str, Any], value) - - return combined - - def _construct_request_from_snapshot(self, snapshot: dict[str, Any]) -> RequestInfoMessage | None: - details_raw = snapshot.get("details") - details: dict[str, Any] = cast(dict[str, Any], details_raw) if isinstance(details_raw, dict) else {} - - request_cls: type[RequestInfoMessage] = RequestInfoMessage - request_type_str = snapshot.get("request_type") - if isinstance(request_type_str, str) and ":" in request_type_str: - module_name, class_name = request_type_str.split(":", 1) - try: - module = importlib.import_module(module_name) - candidate = getattr(module, class_name) - if isinstance(candidate, type) and issubclass(candidate, RequestInfoMessage): - request_cls = candidate - except Exception as exc: - logger.warning(f"RequestInfoExecutor {self.id} could not import {module_name}.{class_name}: {exc}") - request_cls = RequestInfoMessage - - request: RequestInfoMessage | None = self._instantiate_request(request_cls, details) - - if request is None and request_cls is not RequestInfoMessage: - request = self._instantiate_request(RequestInfoMessage, details) - - if request is None: - logger.warning( - f"RequestInfoExecutor {self.id} could not reconstruct request " - f"{request_type_str or RequestInfoMessage.__name__} from snapshot keys {sorted(details.keys())}" - ) - return None - - for key, value in details.items(): - if key == "request_id": - continue - try: - setattr(request, key, value) - except Exception as exc: - logger.debug( - f"RequestInfoExecutor {self.id} could not set attribute {key} on {type(request).__name__}: {exc}" - ) - continue - - snapshot_request_id = snapshot.get("request_id") - if isinstance(snapshot_request_id, str) and snapshot_request_id: - try: - request.request_id = snapshot_request_id - except Exception as exc: - logger.debug( - f"RequestInfoExecutor {self.id} could not apply snapshot " - f"request_id to {type(request).__name__}: {exc}" - ) - - return request - - def _instantiate_request( - self, - request_cls: type[RequestInfoMessage], - details: dict[str, Any], - ) -> RequestInfoMessage | None: - try: - from_dict = getattr(request_cls, "from_dict", None) - if callable(from_dict): - return cast(RequestInfoMessage, from_dict(details)) - except (TypeError, ValueError) as exc: - logger.debug(f"RequestInfoExecutor {self.id} failed to hydrate {request_cls.__name__} via from_dict: {exc}") - except Exception as exc: - logger.warning( - f"RequestInfoExecutor {self.id} encountered unexpected error during " - f"{request_cls.__name__}.from_dict: {exc}" - ) - - if is_dataclass(request_cls): - try: - field_names = {f.name for f in fields(request_cls)} - ctor_kwargs = {name: details[name] for name in field_names if name in details} - return request_cls(**ctor_kwargs) - except (TypeError, ValueError) as exc: - logger.debug( - f"RequestInfoExecutor {self.id} could not instantiate dataclass " - f"{request_cls.__name__} with snapshot data: {exc}" - ) - except Exception as exc: - logger.warning( - f"RequestInfoExecutor {self.id} encountered unexpected error " - f"constructing dataclass {request_cls.__name__}: {exc}" - ) - - try: - instance = request_cls() - except Exception as exc: - logger.warning( - f"RequestInfoExecutor {self.id} could not instantiate {request_cls.__name__} without arguments: {exc}" - ) - return None - - for key, value in details.items(): - if key == "request_id": - continue - try: - setattr(instance, key, value) - except Exception as exc: - logger.debug( - f"RequestInfoExecutor {self.id} could not set attribute {key} on " - f"{request_cls.__name__} during instantiation: {exc}" - ) - continue - - return instance - - @staticmethod - def pending_requests_from_checkpoint( - checkpoint: WorkflowCheckpoint, - *, - request_executor_ids: Iterable[str] | None = None, - ) -> list[PendingRequestDetails]: - executor_filter: set[str] | None = None - if request_executor_ids is not None: - executor_filter = {str(value) for value in request_executor_ids} - - pending: dict[str, PendingRequestDetails] = {} - - shared_map = checkpoint.shared_state.get(RequestInfoExecutor._PENDING_SHARED_STATE_KEY) - if isinstance(shared_map, Mapping): - for request_id, snapshot in shared_map.items(): # type: ignore[attr-defined] - RequestInfoExecutor._merge_snapshot(pending, str(request_id), snapshot) # type: ignore[arg-type] - - for state in checkpoint.executor_states.values(): - if not isinstance(state, Mapping): - continue - inner = state.get("pending_requests") - if isinstance(inner, Mapping): - for request_id, snapshot in inner.items(): # type: ignore[attr-defined] - RequestInfoExecutor._merge_snapshot(pending, str(request_id), snapshot) # type: ignore[arg-type] - - for source_id, message_list in checkpoint.messages.items(): - if executor_filter is not None and source_id not in executor_filter: - continue - if not isinstance(message_list, list): - continue - for message in message_list: - if not isinstance(message, Mapping): - continue - payload = _decode_checkpoint_value(message.get("data")) - RequestInfoExecutor._merge_message_payload(pending, payload, message) - - return list(pending.values()) - - @staticmethod - def checkpoint_summary( - checkpoint: WorkflowCheckpoint, - *, - request_executor_ids: Iterable[str] | None = None, - preview_width: int = 70, - ) -> WorkflowCheckpointSummary: - targets = sorted(checkpoint.messages.keys()) - executor_states = sorted(checkpoint.executor_states.keys()) - pending = RequestInfoExecutor.pending_requests_from_checkpoint( - checkpoint, request_executor_ids=request_executor_ids - ) - - draft_preview: str | None = None - for entry in pending: - if entry.draft: - draft_preview = shorten(entry.draft, width=preview_width, placeholder="…") - break - - status = "idle" - if pending: - status = "awaiting human response" - elif not checkpoint.messages and "finalise" in executor_states: - status = "completed" - elif checkpoint.messages: - status = "awaiting next superstep" - elif request_executor_ids is not None and any(tid in targets for tid in request_executor_ids): - status = "awaiting request delivery" - - return WorkflowCheckpointSummary( - checkpoint_id=checkpoint.checkpoint_id, - iteration_count=checkpoint.iteration_count, - targets=targets, - executor_states=executor_states, - status=status, - draft_preview=draft_preview, - pending_requests=pending, - ) - - @staticmethod - def _merge_snapshot( - pending: dict[str, PendingRequestDetails], - request_id: str, - snapshot: Any, - ) -> None: - if not request_id or not isinstance(snapshot, Mapping): - return - - details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) - - RequestInfoExecutor._apply_update( - details, - prompt=snapshot.get("prompt"), # type: ignore[attr-defined] - draft=snapshot.get("draft"), # type: ignore[attr-defined] - iteration=snapshot.get("iteration"), # type: ignore[attr-defined] - source_executor_id=snapshot.get("source_executor_id"), # type: ignore[attr-defined] - ) - - extra = snapshot.get("details") # type: ignore[attr-defined] - if isinstance(extra, Mapping): - RequestInfoExecutor._apply_update( - details, - prompt=extra.get("prompt"), # type: ignore[attr-defined] - draft=extra.get("draft"), # type: ignore[attr-defined] - iteration=extra.get("iteration"), # type: ignore[attr-defined] - ) - - @staticmethod - def _merge_message_payload( - pending: dict[str, PendingRequestDetails], - payload: Any, - raw_message: Mapping[str, Any], - ) -> None: - if isinstance(payload, RequestResponse): - request_id = payload.request_id or RequestInfoExecutor._get_field(payload.original_request, "request_id") # type: ignore[arg-type] - if not request_id: - return - details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) - RequestInfoExecutor._apply_update( - details, - prompt=RequestInfoExecutor._get_field(payload.original_request, "prompt"), # type: ignore[arg-type] - draft=RequestInfoExecutor._get_field(payload.original_request, "draft"), # type: ignore[arg-type] - iteration=RequestInfoExecutor._get_field(payload.original_request, "iteration"), # type: ignore[arg-type] - source_executor_id=raw_message.get("source_id"), - original_request=payload.original_request, # type: ignore[arg-type] - ) - elif isinstance(payload, RequestInfoMessage): - request_id = getattr(payload, "request_id", None) - if not request_id: - return - details = pending.setdefault(request_id, PendingRequestDetails(request_id=request_id)) - RequestInfoExecutor._apply_update( - details, - prompt=getattr(payload, "prompt", None), - draft=getattr(payload, "draft", None), - iteration=getattr(payload, "iteration", None), - source_executor_id=raw_message.get("source_id"), - original_request=payload, - ) - - @staticmethod - def _apply_update( - details: PendingRequestDetails, - *, - prompt: Any = None, - draft: Any = None, - iteration: Any = None, - source_executor_id: Any = None, - original_request: Any = None, - ) -> None: - if prompt and not details.prompt: - details.prompt = str(prompt) - if draft and not details.draft: - details.draft = str(draft) - if iteration is not None and details.iteration is None: - coerced = RequestInfoExecutor._coerce_int(iteration) - if coerced is not None: - details.iteration = coerced - if source_executor_id and not details.source_executor_id: - details.source_executor_id = str(source_executor_id) - if original_request is not None and details.original_request is None: - details.original_request = original_request - - @staticmethod - def _get_field(obj: Any, key: str) -> Any: - if obj is None: - return None - if isinstance(obj, Mapping): - return obj.get(key) # type: ignore[attr-defined,return-value] - return getattr(obj, key, None) - - @staticmethod - def _coerce_int(value: Any) -> int | None: - try: - return int(value) - except (TypeError, ValueError): - return None - - -# endregion: Request Info Executor diff --git a/python/packages/core/agent_framework/_workflows/_magentic.py b/python/packages/core/agent_framework/_workflows/_magentic.py index 0f61f1ca7e1..3ee1c10690f 100644 --- a/python/packages/core/agent_framework/_workflows/_magentic.py +++ b/python/packages/core/agent_framework/_workflows/_magentic.py @@ -27,8 +27,9 @@ from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._events import WorkflowEvent -from ._executor import Executor, RequestInfoMessage, RequestResponse, handler +from ._executor import Executor, handler from ._model_utils import DictConvertible, encode_value +from ._request_info_executor import RequestInfoMessage, RequestResponse from ._workflow import Workflow, WorkflowBuilder, WorkflowRunResult from ._workflow_context import WorkflowContext @@ -971,7 +972,6 @@ def __init__( self._require_plan_signoff = require_plan_signoff self._plan_review_round = 0 self._max_plan_review_rounds = max_plan_review_rounds - self._inner_loop_lock = asyncio.Lock() # Registry of agent executors for internal coordination (e.g., resets) self._agent_executors = {} # Terminal state marker to stop further processing after completion/limits @@ -1103,8 +1103,6 @@ async def handle_start_message( task=message.task, participant_descriptions=self._participants, ) - # Record the original user task in orchestrator context (no broadcast) - self._context.chat_history.append(message.task) self._state_restored = True # Non-streaming callback for the orchestrator receipt of the task if self._message_callback: @@ -1316,10 +1314,10 @@ async def _run_inner_loop( """Run the inner orchestration loop. Coordination phase. Serialized with a lock.""" if self._context is None or self._task_ledger is None: raise RuntimeError("Context or task ledger not initialized") - async with self._inner_loop_lock: - await self._run_inner_loop_locked(context) - async def _run_inner_loop_locked( + await self._run_inner_loop_helper(context) + + async def _run_inner_loop_helper( self, context: WorkflowContext[MagenticResponseMessage | MagenticRequestMessage, ChatMessage], ) -> None: @@ -1939,7 +1937,7 @@ async def _on_agent_delta(agent_id: str, update: AgentRunResponseUpdate, is_fina workflow_builder = WorkflowBuilder().set_start_executor(orchestrator_executor) if self._enable_plan_review: - from ._executor import RequestInfoExecutor + from ._request_info_executor import RequestInfoExecutor request_info = RequestInfoExecutor(id="magentic_plan_review") workflow_builder = ( diff --git a/python/packages/core/agent_framework/_workflows/_request_info_executor.py b/python/packages/core/agent_framework/_workflows/_request_info_executor.py new file mode 100644 index 00000000000..6b3c710f62e --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_request_info_executor.py @@ -0,0 +1,573 @@ +# Copyright (c) Microsoft. All rights reserved. + +import contextlib +import importlib +import json +import logging +import uuid +from collections.abc import Mapping, Sequence +from dataclasses import asdict, dataclass, field, fields, is_dataclass +from typing import Any, ClassVar, Generic, TypeVar, cast + +from ._events import ( + RequestInfoEvent, # type: ignore[reportPrivateUsage] +) +from ._executor import Executor, handler +from ._workflow_context import WorkflowContext + +logger = logging.getLogger(__name__) + + +@dataclass +class PendingRequestDetails: + """Lightweight information about a pending request captured in a checkpoint.""" + + request_id: str + prompt: str | None = None + draft: str | None = None + iteration: int | None = None + source_executor_id: str | None = None + original_request: "RequestInfoMessage | dict[str, Any] | None" = None + + +@dataclass +class PendingRequestSnapshot: + """Snapshot of a pending request for internal tracking. + + This snapshot should be JSON-serializable and contain enough + information to reconstruct the original request if needed. + """ + + request_id: str + source_executor_id: str + request_type: str + request_as_json_safe_dict: dict[str, Any] + + +@dataclass +class RequestInfoMessage: + """Base class for all request messages in workflows. + + Any message that should be routed to the RequestInfoExecutor for external + handling must inherit from this class. This ensures type safety and makes + the request/response pattern explicit. + """ + + request_id: str = field(default_factory=lambda: str(uuid.uuid4())) + """Unique identifier for correlating requests and responses.""" + + source_executor_id: str | None = None + """ID of the executor expecting a response to this request. + May differ from the executor that sent the request if intercepted and forwarded.""" + + +TRequest = TypeVar("TRequest", bound="RequestInfoMessage") +TResponse = TypeVar("TResponse") + + +@dataclass +class RequestResponse(Generic[TRequest, TResponse]): + """Response type for request/response correlation in workflows. + + This type is used by RequestInfoExecutor to create correlated responses + that include the original request context for proper message routing. + """ + + data: TResponse + """The response data returned from handling the request.""" + + original_request: TRequest + """The original request that this response corresponds to.""" + + request_id: str + """The ID of the original request.""" + + +# endregion: Request/Response Types + + +# region Request Info Executor +class RequestInfoExecutor(Executor): + """Built-in executor that handles request/response patterns in workflows. + + This executor acts as a gateway for external information requests. When it receives + a request message, it saves the request details and emits a RequestInfoEvent. When + a response is provided externally, it emits the response as a message. + """ + + _PENDING_SHARED_STATE_KEY: ClassVar[str] = "_af_pending_request_info" + + def __init__(self, id: str): + """Initialize the RequestInfoExecutor with a unique ID. + + Args: + id: Unique ID for this RequestInfoExecutor. + """ + super().__init__(id=id) + self._request_events: dict[str, RequestInfoEvent] = {} + + # region Public Methods + + @handler + async def handle_request(self, message: RequestInfoMessage, ctx: WorkflowContext) -> None: + """Run the RequestInfoExecutor with the given message.""" + # Use source_executor_id from message if available, otherwise fall back to context + source_executor_id = message.source_executor_id or ctx.get_source_executor_id() + + event = RequestInfoEvent( + request_id=message.request_id, + source_executor_id=source_executor_id, + request_type=type(message), + request_data=message, + ) + self._request_events[message.request_id] = event + await self._record_pending_request(message, source_executor_id, ctx) + await ctx.add_event(event) + + async def handle_response( + self, + response_data: Any, + request_id: str, + ctx: WorkflowContext[RequestResponse[RequestInfoMessage, Any]], + ) -> None: + """Handle a response to a request. + + Args: + request_id: The ID of the request to which this response corresponds. + response_data: The data returned in the response. + ctx: The workflow context for sending the response. + """ + event = self._request_events.get(request_id) + if event is None: + event = await self._rehydrate_request_event(request_id, cast(WorkflowContext, ctx)) + if event is None: + raise ValueError(f"No request found with ID: {request_id}") + + self._request_events.pop(request_id, None) + + # Create a correlated response that includes both the response data and original request + if not isinstance(event.data, RequestInfoMessage): + raise TypeError(f"Expected RequestInfoMessage, got {type(event.data)}") + correlated_response = RequestResponse(data=response_data, original_request=event.data, request_id=request_id) + await ctx.send_message(correlated_response, target_id=event.source_executor_id) + + await self._erase_pending_request(request_id, cast(WorkflowContext, ctx)) + + def snapshot_state(self) -> dict[str, Any]: + """Serialize pending requests so checkpoint restoration can resume seamlessly.""" + + def _encode_event(event: RequestInfoEvent) -> dict[str, Any] | None: + if event.data is None or not isinstance(event.data, RequestInfoMessage): + logger.warning( + f"RequestInfoExecutor {self.id} encountered invalid event data for request ID {event.request_id}: " + f"{type(event.data).__name__}. This request will be skipped in the checkpoint." + ) + return None + + payload = self._encode_request_payload(event.data, event.data.__class__) + + return { + "source_executor_id": event.source_executor_id, + "request_type": f"{event.request_type.__module__}:{event.request_type.__qualname__}", + "request_data": payload, + } + + return { + "request_events": { + rid: encoded + for rid, event in self._request_events.items() + if (encoded := _encode_event(event)) is not None + }, + } + + def restore_state(self, state: dict[str, Any]) -> None: + """Restore pending request bookkeeping from checkpoint state.""" + self._request_events.clear() + stored_events = state.get("request_events", {}) + + for request_id, payload in stored_events.items(): + request_type_qual = payload.get("request_type", "") + try: + request_type = _import_qualname(request_type_qual) + except Exception as exc: # pragma: no cover - defensive fallback + logger.debug( + "RequestInfoExecutor %s failed to import %s during restore: %s", + self.id, + request_type_qual, + exc, + ) + request_type = RequestInfoMessage + request_data_meta = payload.get("request_data", {}) + request_data = self._decode_request_data(request_data_meta) + event = RequestInfoEvent( + request_id=request_id, + source_executor_id=payload.get("source_executor_id", ""), + request_type=request_type, + request_data=request_data, + ) + self._request_events[request_id] = event + + async def has_pending_request(self, request_id: str, ctx: WorkflowContext) -> bool: + """Check if there is a pending request with the given ID. + + Args: + request_id: The ID of the request to check. + ctx: The workflow context for accessing state if needed. + + Returns: True if the request is pending, False otherwise. + """ + if request_id in self._request_events: + return True + + pending_requests = await self._retrieve_existing_pending_requests(ctx) + return request_id in pending_requests + + # endregion: Public Methods + + # region: Internal Methods + + async def _record_pending_request( + self, + message: RequestInfoMessage, + source_executor_id: str, + ctx: WorkflowContext, + ) -> None: + """Record a pending request to the executor's state for checkpointing purposes.""" + pending_request_snapshot = self._build_pending_request_snapshot(message, source_executor_id) + + existing_pending_requests = await self._retrieve_existing_pending_requests(ctx) + existing_pending_requests[message.request_id] = pending_request_snapshot + + await self._persist_to_executor_state(existing_pending_requests, ctx) + + async def _erase_pending_request(self, request_id: str, ctx: WorkflowContext) -> None: + """Erase a pending request from the executor's state after it has been handled for checkpointing purposes.""" + existing_pending_requests = await self._retrieve_existing_pending_requests(ctx) + if request_id in existing_pending_requests: + existing_pending_requests.pop(request_id) + await self._persist_to_executor_state(existing_pending_requests, ctx) + + async def _retrieve_existing_pending_requests(self, ctx: WorkflowContext) -> dict[str, PendingRequestSnapshot]: + """Retrieve existing pending requests from executor state.""" + executor_state = await ctx.get_state() + if executor_state is None: + return {} + + stored_requests = executor_state.get(self._PENDING_SHARED_STATE_KEY, {}) + if not isinstance(stored_requests, dict): + raise TypeError(f"Unexpected type for pending requests: {type(stored_requests).__name__}") + + # Validate contents + for key, value in stored_requests.items(): # type: ignore + if not isinstance(key, str) or not isinstance(value, PendingRequestSnapshot): + raise TypeError( + "Invalid pending request entry in executor state. " + "Key must be `str` and value must be `PendingRequestSnapshot`." + ) + + return stored_requests # type: ignore + + async def _persist_to_executor_state( + self, pending: dict[str, PendingRequestSnapshot], ctx: WorkflowContext + ) -> None: + """Persist the current pending requests to the executor's state.""" + executor_state = await ctx.get_state() or {} + executor_state[self._PENDING_SHARED_STATE_KEY] = pending + await ctx.set_state(executor_state) + + def _build_pending_request_snapshot( + self, request: RequestInfoMessage, source_executor_id: str + ) -> PendingRequestSnapshot: + """Build a snapshot of the pending request for checkpointing.""" + request_as_json_safe_dict = self._convert_request_to_json_safe_dict(request) + + return PendingRequestSnapshot( + request_id=request.request_id, + source_executor_id=source_executor_id, + request_type=f"{type(request).__module__}:{type(request).__name__}", + request_as_json_safe_dict=request_as_json_safe_dict, + ) + + def _encode_request_payload(self, request_data: RequestInfoMessage, data_cls: type[Any]) -> dict[str, Any]: + if is_dataclass(request_data) and not isinstance(request_data, type): + dataclass_instance = cast(Any, request_data) + safe_value = _make_json_safe(asdict(dataclass_instance)) + return { + "kind": "dataclass", + "type": f"{data_cls.__module__}:{data_cls.__qualname__}", + "value": safe_value, + } + + to_dict_fn = getattr(request_data, "to_dict", None) + if callable(to_dict_fn): + try: + dumped = to_dict_fn() + except TypeError: + dumped = to_dict_fn() + safe_value = _make_json_safe(dumped) + return { + "kind": "dict", + "type": f"{data_cls.__module__}:{data_cls.__qualname__}", + "value": safe_value, + } + + to_json_fn = getattr(request_data, "to_json", None) + if callable(to_json_fn): + try: + dumped = to_json_fn() + except TypeError: + dumped = to_json_fn() + converted = dumped + if isinstance(dumped, (str, bytes, bytearray)): + decoded: str | bytes | bytearray + if isinstance(dumped, (bytes, bytearray)): + try: + decoded = dumped.decode() + except Exception: + decoded = dumped + else: + decoded = dumped + try: + converted = json.loads(decoded) + except Exception: + converted = decoded + safe_value = _make_json_safe(converted) + return { + "kind": "dict" if isinstance(converted, dict) else "json", + "type": f"{data_cls.__module__}:{data_cls.__qualname__}", + "value": safe_value, + } + + return { + "kind": "raw", + "type": f"{data_cls.__module__}:{data_cls.__qualname__}", + "value": self._convert_request_to_json_safe_dict(request_data), + } + + def _decode_request_data(self, metadata: dict[str, Any]) -> RequestInfoMessage: + kind = metadata.get("kind") + type_name = metadata.get("type", "") + value: Any = metadata.get("value", {}) + if type_name: + try: + imported = _import_qualname(type_name) + except Exception as exc: # pragma: no cover - defensive fallback + logger.debug( + "RequestInfoExecutor %s failed to import %s during decode: %s", + self.id, + type_name, + exc, + ) + imported = RequestInfoMessage + else: + imported = RequestInfoMessage + target_cls: type[RequestInfoMessage] + if isinstance(imported, type) and issubclass(imported, RequestInfoMessage): + target_cls = imported + else: + target_cls = RequestInfoMessage + + if kind == "dataclass" and isinstance(value, dict): + with contextlib.suppress(TypeError): + return target_cls(**value) # type: ignore[arg-type] + + # Backwards-compat handling for checkpoints that used to store pydantic as "dict" + if kind in {"dict", "pydantic", "json"} and isinstance(value, dict): + from_dict = getattr(target_cls, "from_dict", None) + if callable(from_dict): + with contextlib.suppress(Exception): + return cast(RequestInfoMessage, from_dict(value)) + + if kind == "json" and isinstance(value, str): + from_json = getattr(target_cls, "from_json", None) + if callable(from_json): + with contextlib.suppress(Exception): + return cast(RequestInfoMessage, from_json(value)) + with contextlib.suppress(Exception): + parsed = json.loads(value) + if isinstance(parsed, dict): + return self._decode_request_data({"kind": "dict", "type": type_name, "value": parsed}) + + if isinstance(value, dict): + with contextlib.suppress(TypeError): + return target_cls(**value) # type: ignore[arg-type] + instance = object.__new__(target_cls) + instance.__dict__.update(value) # type: ignore[arg-type] + return instance + + with contextlib.suppress(Exception): + return target_cls() + return RequestInfoMessage() + + def _convert_request_to_json_safe_dict(self, request: RequestInfoMessage) -> dict[str, Any]: + try: + data = _make_json_safe(asdict(request)) + if isinstance(data, dict): + return cast(dict[str, Any], data) + raise ValueError(f"Failed to convert {type(request).__name__} to dict") + except Exception as exc: + logger.error(f"RequestInfoExecutor {self.id} failed to serialize request: {exc}") + raise RuntimeError( + f"Failed to serialize request `{type(request).__name__}`: {exc}\n" + "Make sure request is a dataclass and derive from `RequestInfoMessage`." + ) from exc + + async def _rehydrate_request_event(self, request_id: str, ctx: WorkflowContext) -> RequestInfoEvent | None: + pending_requests = await self._retrieve_existing_pending_requests(ctx) + if (snapshot := pending_requests.get(request_id)) is None: + return None + + request = self._construct_request_from_snapshot(snapshot) + if request is None: + return None + + event = RequestInfoEvent( + request_id=request_id, + source_executor_id=snapshot.source_executor_id, + request_type=type(request), + request_data=request, + ) + self._request_events[request_id] = event + return event + + def _construct_request_from_snapshot(self, snapshot: PendingRequestSnapshot) -> RequestInfoMessage | None: + json_safe_dict = snapshot.request_as_json_safe_dict + + request_cls: type[RequestInfoMessage] = RequestInfoMessage + request_type_str = snapshot.request_type + if isinstance(request_type_str, str) and ":" in request_type_str: + module_name, class_name = request_type_str.split(":", 1) + try: + module = importlib.import_module(module_name) + candidate = getattr(module, class_name) + if isinstance(candidate, type) and issubclass(candidate, RequestInfoMessage): + request_cls = candidate + except Exception as exc: + logger.warning(f"RequestInfoExecutor {self.id} could not import {module_name}.{class_name}: {exc}") + request_cls = RequestInfoMessage + + request: RequestInfoMessage | None = self._instantiate_request(request_cls, json_safe_dict) + + if request is None and request_cls is not RequestInfoMessage: + request = self._instantiate_request(RequestInfoMessage, json_safe_dict) + + if request is None: + logger.warning( + f"RequestInfoExecutor {self.id} could not reconstruct request " + f"{request_type_str or RequestInfoMessage.__name__} from snapshot keys {sorted(json_safe_dict.keys())}" + ) + return None + + for key, value in json_safe_dict.items(): + if key == "request_id": + continue + try: + setattr(request, key, value) + except Exception as exc: + logger.debug( + f"RequestInfoExecutor {self.id} could not set attribute {key} on {type(request).__name__}: {exc}" + ) + continue + + snapshot_request_id = snapshot.request_id + if isinstance(snapshot_request_id, str) and snapshot_request_id: + try: + request.request_id = snapshot_request_id + except Exception as exc: + logger.debug( + f"RequestInfoExecutor {self.id} could not apply snapshot " + f"request_id to {type(request).__name__}: {exc}" + ) + + return request + + def _instantiate_request( + self, + request_cls: type[RequestInfoMessage], + details: dict[str, Any], + ) -> RequestInfoMessage | None: + try: + from_dict = getattr(request_cls, "from_dict", None) + if callable(from_dict): + return cast(RequestInfoMessage, from_dict(details)) + except (TypeError, ValueError) as exc: + logger.debug(f"RequestInfoExecutor {self.id} failed to hydrate {request_cls.__name__} via from_dict: {exc}") + except Exception as exc: + logger.warning( + f"RequestInfoExecutor {self.id} encountered unexpected error during " + f"{request_cls.__name__}.from_dict: {exc}" + ) + + if is_dataclass(request_cls): + try: + field_names = {f.name for f in fields(request_cls)} + ctor_kwargs = {name: details[name] for name in field_names if name in details} + return request_cls(**ctor_kwargs) + except (TypeError, ValueError) as exc: + logger.debug( + f"RequestInfoExecutor {self.id} could not instantiate dataclass " + f"{request_cls.__name__} with snapshot data: {exc}" + ) + except Exception as exc: + logger.warning( + f"RequestInfoExecutor {self.id} encountered unexpected error " + f"constructing dataclass {request_cls.__name__}: {exc}" + ) + + try: + instance = request_cls() + except Exception as exc: + logger.warning( + f"RequestInfoExecutor {self.id} could not instantiate {request_cls.__name__} without arguments: {exc}" + ) + return None + + for key, value in details.items(): + if key == "request_id": + continue + try: + setattr(instance, key, value) + except Exception as exc: + logger.debug( + f"RequestInfoExecutor {self.id} could not set attribute {key} on " + f"{request_cls.__name__} during instantiation: {exc}" + ) + continue + + return instance + + # endregion: Internal Methods + + +# region: Utility Functions + + +def _make_json_safe(value: Any) -> Any: + """Recursively convert a value to a JSON-safe representation.""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Mapping): + safe_dict: dict[str, Any] = {} + for key, val in value.items(): # type: ignore[attr-defined] + safe_dict[str(key)] = _make_json_safe(val) # type: ignore[arg-type] + return safe_dict + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_make_json_safe(item) for item in value] # type: ignore[misc] + return repr(value) + + +def _import_qualname(qualname: str) -> type[Any]: + """Import a type given its qualified name in the format 'module:TypeName'.""" + module_name, _, type_name = qualname.partition(":") + if not module_name or not type_name: + raise ValueError(f"Invalid qualified name: {qualname}") + module = importlib.import_module(module_name) + attr: Any = module + for part in type_name.split("."): + attr = getattr(attr, part) + if not isinstance(attr, type): + raise TypeError(f"Resolved object is not a type: {qualname}") + return attr + + +# endregion: Utility Functions diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index a8f845637e5..9a0d0a77904 100644 --- a/python/packages/core/agent_framework/_workflows/_runner.py +++ b/python/packages/core/agent_framework/_workflows/_runner.py @@ -22,7 +22,7 @@ from ._shared_state import SharedState if TYPE_CHECKING: - from ._executor import RequestInfoExecutor + from ._request_info_executor import RequestInfoExecutor logger = logging.getLogger(__name__) @@ -304,6 +304,8 @@ async def restore_from_checkpoint( checkpoint_id, ) + await self._restore_executor_states(checkpoint.executor_states) + state = self._checkpoint_to_state(checkpoint) await self._ctx.set_checkpoint_state(state) if checkpoint.workflow_id: @@ -323,6 +325,27 @@ async def restore_from_checkpoint( logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}") return False + async def _restore_executor_states(self, executor_states: dict[str, dict[str, Any]]) -> None: + for exec_id, state in executor_states.items(): + executor = self._executors.get(exec_id) + if not executor: + logger.debug(f"Executor {exec_id} not found during state restoration; skipping.") + continue + + restored = False + restore_method = getattr(executor, "restore_state", None) + try: + if callable(restore_method): + maybe = restore_method(state) + if asyncio.iscoroutine(maybe): # type: ignore[arg-type] + await maybe # type: ignore[arg-type] + restored = True + except Exception as ex: # pragma: no cover - defensive + logger.debug(f"Executor {exec_id} restore_state failed: {ex}") + + if not restored: + logger.debug(f"Executor {exec_id} does not support state restoration; skipping.") + async def _restore_shared_state_from_context(self) -> None: try: restored_state = await self._ctx.get_checkpoint_state() @@ -372,7 +395,7 @@ def _find_request_info_executor(self) -> "RequestInfoExecutor | None": Returns: The RequestInfoExecutor instance if found, None otherwise. """ - from ._executor import RequestInfoExecutor + from ._request_info_executor import RequestInfoExecutor for executor in self._executors.values(): if isinstance(executor, RequestInfoExecutor): @@ -388,7 +411,7 @@ def _is_message_to_request_info_executor(self, msg: "Message") -> bool: Returns: True if the message targets a RequestInfoExecutor, False otherwise. """ - from ._executor import RequestInfoExecutor + from ._request_info_executor import RequestInfoExecutor if not msg.target_id: return False diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py index 5dc135d6747..4de70cb5912 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -12,7 +12,7 @@ from ._checkpoint import CheckpointStorage, WorkflowCheckpoint from ._const import DEFAULT_MAX_ITERATIONS -from ._events import AgentRunUpdateEvent, WorkflowEvent +from ._events import WorkflowEvent from ._shared_state import SharedState logger = logging.getLogger(__name__) @@ -487,28 +487,6 @@ async def add_event(self, event: WorkflowEvent) -> None: Events are enqueued so runners can stream them in real time instead of waiting for superstep boundaries. """ - # Filter out empty AgentRunUpdateEvent updates to avoid emitting None/empty chunks - try: - if isinstance(event, AgentRunUpdateEvent): - update = getattr(event, "data", None) - # Skip if no update payload - if not update: - return - # Robust emptiness check: allow either top-level text or any text-bearing content - text_val = getattr(update, "text", None) - contents = getattr(update, "contents", None) - has_text_content = False - if contents: - for c in contents: - if getattr(c, "text", None): - has_text_content = True - break - if not (text_val or has_text_content): - return - except Exception as exc: # pragma: no cover - defensive logging path - # Best-effort filtering only; never block event delivery on filtering errors - logger.debug(f"Error while filtering event {event!r}: {exc}", exc_info=True) - await self._event_queue.put(event) async def drain_events(self) -> list[WorkflowEvent]: diff --git a/python/packages/core/agent_framework/_workflows/_validation.py b/python/packages/core/agent_framework/_workflows/_validation.py index 95f522c2251..5cd7940ff34 100644 --- a/python/packages/core/agent_framework/_workflows/_validation.py +++ b/python/packages/core/agent_framework/_workflows/_validation.py @@ -9,7 +9,8 @@ from typing import Any, Union, get_args, get_origin from ._edge import Edge, EdgeGroup, FanInEdgeGroup -from ._executor import Executor, RequestInfoExecutor +from ._executor import Executor +from ._request_info_executor import RequestInfoExecutor logger = logging.getLogger(__name__) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 0782dbd1cdd..d9270bfe02b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -37,8 +37,9 @@ WorkflowStatusEvent, _framework_event_origin, # type: ignore ) -from ._executor import Executor, RequestInfoExecutor +from ._executor import Executor from ._model_utils import DictConvertible +from ._request_info_executor import RequestInfoExecutor from ._runner import Runner from ._runner_context import InProcRunnerContext, RunnerContext from ._shared_state import SharedState @@ -742,7 +743,7 @@ def _find_request_info_executor(self) -> RequestInfoExecutor | None: Returns: The RequestInfoExecutor instance if found, None otherwise. """ - from ._executor import RequestInfoExecutor + from ._request_info_executor import RequestInfoExecutor for executor in self.executors.values(): if isinstance(executor, RequestInfoExecutor): diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index b55966fb7a9..b7e077424ee 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -441,14 +441,11 @@ async def set_state(self, state: dict[str, Any]) -> None: Executors call this with a JSON-serializable dict capturing the minimal state needed to resume. It replaces any previously stored state. """ - if hasattr(self._runner_context, "set_state"): - await self._runner_context.set_state(self._executor_id, state) # type: ignore[arg-type] + await self._runner_context.set_state(self._executor_id, state) async def get_state(self) -> dict[str, Any] | None: """Retrieve previously persisted state for this executor, if any.""" - if hasattr(self._runner_context, "get_state"): - return await self._runner_context.get_state(self._executor_id) # type: ignore[return-value] - return None + return await self._runner_context.get_state(self._executor_id) def is_streaming(self) -> bool: """Check if the workflow is running in streaming mode. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 55f1fab2649..501ce0d8f1a 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -19,10 +19,12 @@ ) from ._executor import ( Executor, + handler, +) +from ._request_info_executor import ( RequestInfoExecutor, RequestInfoMessage, RequestResponse, - handler, ) from ._typing_utils import is_instance_of from ._workflow_context import WorkflowContext diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index 093a4086f17..e3eb37b26ec 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -243,4 +243,4 @@ def __init__( def_headers = None self.default_headers = def_headers - super().__init__(model_id=deployment_name, client=client) + super().__init__(model_id=deployment_name, client=client, **kwargs) diff --git a/python/packages/core/agent_framework/microsoft/__init__.py b/python/packages/core/agent_framework/microsoft/__init__.py index 55022357bf0..2874488829e 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.py +++ b/python/packages/core/agent_framework/microsoft/__init__.py @@ -3,12 +3,20 @@ import importlib from typing import Any -PACKAGE_NAME = "agent_framework_copilotstudio" -PACKAGE_EXTRA = ["microsoft-copilotstudio", "copilotstudio"] _IMPORTS: dict[str, tuple[str, list[str]]] = { "CopilotStudioAgent": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), "__version__": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), "acquire_token": ("agent_framework_copilotstudio", ["microsoft-copilotstudio", "copilotstudio"]), + # Purview (Graph Data Security & Governance) integration exports + "PurviewPolicyMiddleware": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewChatPolicyMiddleware": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewSettings": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewAppLocation": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewLocationType": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewAuthenticationError": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewRateLimitError": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewRequestError": ("agent_framework_purview", ["microsoft-purview", "purview"]), + "PurviewServiceError": ("agent_framework_purview", ["microsoft-purview", "purview"]), } @@ -23,7 +31,7 @@ def __getattr__(name: str) -> Any: f"please use `pip install agent-framework-{package_extra[0]}`, " "or update your requirements.txt or pyproject.toml file." ) from exc - raise AttributeError(f"Module `azure` has no attribute {name}.") + raise AttributeError(f"Module `microsoft` has no attribute {name}.") def __dir__() -> list[str]: diff --git a/python/packages/core/agent_framework/microsoft/__init__.pyi b/python/packages/core/agent_framework/microsoft/__init__.pyi index c30a7b30313..99ba2af489b 100644 --- a/python/packages/core/agent_framework/microsoft/__init__.pyi +++ b/python/packages/core/agent_framework/microsoft/__init__.pyi @@ -1,5 +1,29 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework_copilotstudio import CopilotStudioAgent, __version__, acquire_token +from agent_framework_purview import ( + PurviewAppLocation, + PurviewAuthenticationError, + PurviewChatPolicyMiddleware, + PurviewLocationType, + PurviewPolicyMiddleware, + PurviewRateLimitError, + PurviewRequestError, + PurviewServiceError, + PurviewSettings, +) -__all__ = ["CopilotStudioAgent", "__version__", "acquire_token"] +__all__ = [ + "CopilotStudioAgent", + "PurviewAppLocation", + "PurviewAuthenticationError", + "PurviewChatPolicyMiddleware", + "PurviewLocationType", + "PurviewPolicyMiddleware", + "PurviewRateLimitError", + "PurviewRequestError", + "PurviewServiceError", + "PurviewSettings", + "__version__", + "acquire_token", +] diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 7fb81ad485a..f5a57683db7 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -425,7 +425,7 @@ def _prepare_options( "json_schema": chat_options.response_format.model_json_schema(), } - instructions: list[str] = [chat_options.instructions] if chat_options and chat_options.instructions else [] + instructions: list[str] = [] tool_results: list[FunctionResultContent] | None = None additional_messages: list[AdditionalMessage] | None = None diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index f36e0b4542f..fdb6a9717fa 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -28,6 +28,8 @@ Contents, DataContent, FinishReason, + FunctionApprovalRequestContent, + FunctionApprovalResponseContent, FunctionCallContent, FunctionResultContent, Role, @@ -154,10 +156,13 @@ def _process_web_search_tool( def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]: # Preprocess web search tool if it exists - options_dict = chat_options.to_dict(exclude={"type"}) - instructions = options_dict.pop("instructions", None) - if instructions: - messages = [ChatMessage(role="system", text=instructions), *messages] + options_dict = chat_options.to_dict( + exclude={ + "type", + "instructions", # included as system message + } + ) + if messages and "messages" not in options_dict: options_dict["messages"] = self._prepare_chat_history_for_request(messages) if "messages" not in options_dict: @@ -353,6 +358,10 @@ def _openai_chat_message_parser(self, message: ChatMessage) -> list[dict[str, An """Parse a chat message into the openai format.""" all_messages: list[dict[str, Any]] = [] for content in message.contents: + # Skip approval content - it's internal framework state, not for the LLM + if isinstance(content, (FunctionApprovalRequestContent, FunctionApprovalResponseContent)): + continue + args: dict[str, Any] = { "role": message.role.value if isinstance(message.role, Role) else message.role, } diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 7d8ade94cb8..ff3871f13e4 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -309,6 +309,7 @@ def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: "logit_bias", # not supported "seed", # not supported "stop", # not supported + "instructions", # already added as system message } ) translations = { @@ -390,6 +391,9 @@ def _openai_chat_message_parser( args["metadata"] = message.additional_properties for content in message.contents: match content: + case TextReasoningContent(): + # Don't send reasoning content back to model + continue case FunctionResultContent(): new_args: dict[str, Any] = {} new_args.update(self._openai_content_parser(message.role, content, call_id_to_id)) @@ -484,6 +488,7 @@ def _openai_content_parser( "type": "function_call", "name": content.name, "arguments": content.arguments, + "status": None, } case FunctionResultContent(): # call_id for the result needs to be the same as the call_id for the function call diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 9909d9abd6c..334f35b79f5 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.0b251007" +version = "1.0.0b251016" 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/azure/test_azure_assistants_client.py b/python/packages/core/tests/azure/test_azure_assistants_client.py index 758be68d3b2..307e6c7ac15 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/core/tests/azure/test_azure_assistants_client.py @@ -15,6 +15,7 @@ ChatAgent, ChatClientProtocol, ChatMessage, + ChatOptions, ChatResponse, ChatResponseUpdate, HostedCodeInterpreterTool, @@ -154,6 +155,18 @@ def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_tes assert chat_client.client.default_headers[key] == value +def test_azure_assistants_client_instructions_sent_once(mock_async_azure_openai: MagicMock) -> None: + """Ensure instructions are only included once for Azure OpenAI Assistants requests.""" + chat_client = create_test_azure_assistants_client(mock_async_azure_openai) + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = chat_client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) + run_options, _ = chat_client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert run_options.get("instructions") == instructions + + async def test_azure_assistants_client_get_assistant_id_or_create_existing_assistant( mock_async_azure_openai: MagicMock, ) -> None: diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/core/tests/azure/test_azure_chat_client.py index aad231ac98e..d43302d472f 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/core/tests/azure/test_azure_chat_client.py @@ -23,6 +23,7 @@ ChatAgent, ChatClientProtocol, ChatMessage, + ChatOptions, ChatResponse, ChatResponseUpdate, TextContent, @@ -83,6 +84,18 @@ def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None: assert azure_chat_client.client.default_headers[key] == value +def test_azure_openai_chat_client_instructions_sent_once(azure_openai_unit_test_env: dict[str, str]) -> None: + """Ensure instructions are only included once when preparing Azure OpenAI chat requests.""" + client = AzureOpenAIChatClient() + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) + request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert json.dumps(request_options).count(instructions) == 1 + + @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: azure_chat_client = AzureOpenAIChatClient() @@ -731,12 +744,12 @@ async def test_azure_openai_chat_client_agent_basic_run(): chat_client=AzureOpenAIChatClient(credential=AzureCliCredential()), ) as agent: # Test basic run - response = await agent.run("Hello! Please respond with 'Hello World' exactly.") + response = await agent.run("Please respond with exactly: 'This is a response test.'") assert isinstance(response, AgentRunResponse) assert response.text is not None assert len(response.text) > 0 - assert "hello world" in response.text.lower() + assert "response test" in response.text.lower() @pytest.mark.flaky diff --git a/python/packages/core/tests/azure/test_azure_responses_client.py b/python/packages/core/tests/azure/test_azure_responses_client.py index a495d058377..658aa21457c 100644 --- a/python/packages/core/tests/azure/test_azure_responses_client.py +++ b/python/packages/core/tests/azure/test_azure_responses_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json import os from typing import Annotated @@ -14,6 +15,7 @@ ChatAgent, ChatClientProtocol, ChatMessage, + ChatOptions, ChatResponse, ChatResponseUpdate, HostedCodeInterpreterTool, @@ -112,6 +114,18 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) -> assert azure_responses_client.client.default_headers[key] == value +def test_azure_responses_client_instructions_sent_once(azure_openai_unit_test_env: dict[str, str]) -> None: + """Ensure instructions are only included once for Azure OpenAI Responses requests.""" + client = AzureOpenAIResponsesClient() + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) + request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert json.dumps(request_options).count(instructions) == 1 + + @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]], indirect=True) def test_init_with_empty_model_id(azure_openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index cc5f248f3e7..7d36debf1ce 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1,7 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. +import contextlib from collections.abc import AsyncIterable, MutableSequence, Sequence from typing import Any +from unittest.mock import AsyncMock, MagicMock from uuid import uuid4 from pytest import raises @@ -23,6 +25,7 @@ Role, TextContent, ) +from agent_framework._mcp import MCPTool from agent_framework.exceptions import AgentExecutionException @@ -506,3 +509,69 @@ async def async_stream_callback(update: AgentRunResponseUpdate) -> None: # Result should be concatenation of all streaming updates expected_text = "".join(update.text for update in collected_updates) assert result == expected_text + + +async def test_chat_agent_as_tool_name_sanitization(chat_client: ChatClientProtocol) -> None: + """Test as_tool name sanitization.""" + test_cases = [ + ("Invoice & Billing Agent", "Invoice_Billing_Agent"), + ("Travel & Logistics Agent", "Travel_Logistics_Agent"), + ("Agent@Company.com", "Agent_Company_com"), + ("Agent___Multiple___Underscores", "Agent_Multiple_Underscores"), + ("123Agent", "_123Agent"), # Test digit prefix handling + ("9to5Helper", "_9to5Helper"), # Another digit prefix case + ("@@@", "agent"), # Test empty sanitization fallback + ] + + for agent_name, expected_tool_name in test_cases: + agent = ChatAgent(chat_client=chat_client, name=agent_name, description="Test agent") + tool = agent.as_tool() + assert tool.name == expected_tool_name, f"Expected {expected_tool_name}, got {tool.name} for input {agent_name}" + + +async def test_chat_agent_as_mcp_server_basic(chat_client: ChatClientProtocol) -> None: + """Test basic as_mcp_server functionality.""" + agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent for MCP") + + # Create MCP server with default parameters + server = agent.as_mcp_server() + + # Verify server is created + assert server is not None + assert hasattr(server, "name") + assert hasattr(server, "version") + + +async def test_chat_agent_run_with_mcp_tools(chat_client: ChatClientProtocol) -> None: + """Test run method with MCP tools to cover MCP tool handling code.""" + agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent") + + # Create a mock MCP tool + mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.is_connected = False + mock_mcp_tool.functions = [MagicMock()] + + # Mock the async context manager entry + mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool) + mock_mcp_tool.__aexit__ = AsyncMock(return_value=None) + + # Test run with MCP tools - this should hit the MCP tool handling code + with contextlib.suppress(Exception): + # We expect this to fail since we're using mocks, but we want to exercise the code path + await agent.run(messages="Test message", tools=[mock_mcp_tool]) + + +async def test_chat_agent_with_local_mcp_tools(chat_client: ChatClientProtocol) -> None: + """Test agent initialization with local MCP tools.""" + # Create a mock MCP tool + mock_mcp_tool = MagicMock(spec=MCPTool) + mock_mcp_tool.is_connected = False + mock_mcp_tool.__aenter__ = AsyncMock(return_value=mock_mcp_tool) + mock_mcp_tool.__aexit__ = AsyncMock(return_value=None) + + # Test agent with MCP tools in constructor + with contextlib.suppress(Exception): + agent = ChatAgent(chat_client=chat_client, name="TestAgent", description="Test agent", tools=[mock_mcp_tool]) + # Test async context manager with MCP tools + async with agent: + pass diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 351e66d87b7..2812f19c9d4 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -252,15 +252,17 @@ def func_with_approval(arg1: str) -> str: # Verify based on scenario (for no thread and local thread cases) if num_functions == 1: if approval_required: - # Single function with approval: call + approval request + # Single function with approval: assistant message contains both call + approval request if not streaming: - assert len(messages) == 2 + assert len(messages) == 1 + # Assistant message should have FunctionCallContent + FunctionApprovalRequestContent + assert len(messages[0].contents) == 2 assert isinstance(messages[0].contents[0], FunctionCallContent) - assert isinstance(messages[1].contents[0], FunctionApprovalRequestContent) - assert messages[1].contents[0].function_call.name == "approval_func" + assert isinstance(messages[0].contents[1], FunctionApprovalRequestContent) + assert messages[0].contents[1].function_call.name == "approval_func" assert exec_counter == 0 # Function not executed yet else: - # Streaming: 2 function call chunks + 1 approval request + # Streaming: 2 function call chunks + 1 approval request update (same assistant message) assert len(messages) == 3 assert isinstance(messages[0].contents[0], FunctionCallContent) assert isinstance(messages[1].contents[0], FunctionCallContent) @@ -288,15 +290,16 @@ def func_with_approval(arg1: str) -> str: else: # num_functions == 2 # Two functions with mixed approval if not streaming: - # Mixed: first message has both calls, second has approval requests for both + # Mixed: assistant message has both calls + approval requests (4 items total) # (because when one requires approval, all are batched for approval) - assert len(messages) == 2 - assert len(messages[0].contents) == 2 # Both function calls + assert len(messages) == 1 + # Should have: 2 FunctionCallContent + 2 FunctionApprovalRequestContent + assert len(messages[0].contents) == 4 assert isinstance(messages[0].contents[0], FunctionCallContent) assert isinstance(messages[0].contents[1], FunctionCallContent) # Both should result in approval requests - assert len(messages[1].contents) == 2 - assert all(isinstance(c, FunctionApprovalRequestContent) for c in messages[1].contents) + approval_requests = [c for c in messages[0].contents if isinstance(c, FunctionApprovalRequestContent)] + assert len(approval_requests) == 2 assert exec_counter == 0 # Neither function executed yet else: # Streaming: 2 function call updates + 1 approval request with 2 contents @@ -344,13 +347,16 @@ def func_rejected(arg1: str) -> str: # Get the response with approval requests response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_approved, func_rejected]) - assert len(response.messages) == 2 - assert len(response.messages[1].contents) == 2 - assert all(isinstance(c, FunctionApprovalRequestContent) for c in response.messages[1].contents) + # Approval requests are now added to the assistant message, not a separate message + assert len(response.messages) == 1 + # Assistant message should have: 2 FunctionCallContent + 2 FunctionApprovalRequestContent + assert len(response.messages[0].contents) == 4 + approval_requests = [c for c in response.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)] + assert len(approval_requests) == 2 # Approve one and reject the other - approval_req_1 = response.messages[1].contents[0] - approval_req_2 = response.messages[1].contents[1] + approval_req_1 = approval_requests[0] + approval_req_2 = approval_requests[1] approved_response = FunctionApprovalResponseContent( id=approval_req_1.id, @@ -391,6 +397,184 @@ def func_rejected(arg1: str) -> str: assert rejected_result.result == "Error: Tool call invocation was rejected by user." assert exec_counter_rejected == 0 + # Verify that messages with FunctionResultContent have role="tool" + # This ensures the message format is correct for OpenAI's API + for msg in all_messages: + for content in msg.contents: + if isinstance(content, FunctionResultContent): + assert msg.role == Role.TOOL, ( + f"Message with FunctionResultContent must have role='tool', got '{msg.role}'" + ) + + +async def test_approval_requests_in_assistant_message(chat_client_base: ChatClientProtocol): + """Approval requests should be added to the assistant message that contains the function call.""" + exec_counter = 0 + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), + ], + ) + ), + ] + + response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval]) + + # Should have one assistant message containing both the call and approval request + assert len(response.messages) == 1 + assert response.messages[0].role == Role.ASSISTANT + assert len(response.messages[0].contents) == 2 + assert isinstance(response.messages[0].contents[0], FunctionCallContent) + assert isinstance(response.messages[0].contents[1], FunctionApprovalRequestContent) + assert exec_counter == 0 + + +async def test_persisted_approval_messages_replay_correctly(chat_client_base: ChatClientProtocol): + """Approval flow should work when messages are persisted and sent back (thread scenario).""" + from agent_framework import FunctionApprovalResponseContent + + exec_counter = 0 + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + nonlocal exec_counter + exec_counter += 1 + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), + ], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + # Get approval request + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval]) + + # Store messages (like a thread would) + persisted_messages = [ + ChatMessage(role="user", contents=[TextContent(text="hello")]), + *response1.messages, + ] + + # Send approval + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + persisted_messages.append(ChatMessage(role="user", contents=[approval_response])) + + # Continue with all persisted messages + response2 = await chat_client_base.get_response(persisted_messages, tool_choice="auto", tools=[func_with_approval]) + + # Should execute successfully + assert response2 is not None + assert exec_counter == 1 + assert response2.messages[-1].text == "done" + + +async def test_no_duplicate_function_calls_after_approval_processing(chat_client_base: ChatClientProtocol): + """Processing approval should not create duplicate function calls in messages.""" + from agent_framework import FunctionApprovalResponseContent + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="1", name="test_func", arguments='{"arg1": "value1"}'), + ], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + approval_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=True, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])] + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_with_approval]) + + # Count function calls with the same call_id + function_call_count = sum( + 1 + for msg in all_messages + for content in msg.contents + if isinstance(content, FunctionCallContent) and content.call_id == "1" + ) + + assert function_call_count == 1 + + +async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClientProtocol): + """Rejection error result should use the function call's call_id, not the approval's id.""" + from agent_framework import FunctionApprovalResponseContent + + @ai_function(name="test_func", approval_mode="always_require") + def func_with_approval(arg1: str) -> str: + return f"Result {arg1}" + + chat_client_base.run_responses = [ + ChatResponse( + messages=ChatMessage( + role="assistant", + contents=[ + FunctionCallContent(call_id="call_123", name="test_func", arguments='{"arg1": "value1"}'), + ], + ) + ), + ChatResponse(messages=ChatMessage(role="assistant", text="done")), + ] + + response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval]) + + approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0] + rejection_response = FunctionApprovalResponseContent( + id=approval_req.id, + function_call=approval_req.function_call, + approved=False, + ) + + all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])] + await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_with_approval]) + + # Find the rejection result + rejection_result = next( + (content for msg in all_messages for content in msg.contents if isinstance(content, FunctionResultContent)), + None, + ) + + assert rejection_result is not None + assert rejection_result.call_id == "call_123" + assert "rejected" in rejection_result.result.lower() + async def test_max_iterations_limit(chat_client_base: ChatClientProtocol): """Test that MAX_ITERATIONS in additional_properties limits function call loops.""" diff --git a/python/packages/core/tests/core/test_serializable_mixin.py b/python/packages/core/tests/core/test_serializable_mixin.py index 1594cb7c5e3..0472f881cf7 100644 --- a/python/packages/core/tests/core/test_serializable_mixin.py +++ b/python/packages/core/tests/core/test_serializable_mixin.py @@ -45,7 +45,7 @@ def __init__(self, value: str, client: Any = None): with caplog.at_level(logging.DEBUG): obj = TestClass.from_dict( {"type": "test_class", "value": "test"}, - dependencies={"test_class.client": mock_client}, + dependencies={"test_class": {"client": mock_client}}, ) assert obj.value == "test" @@ -68,7 +68,7 @@ def __init__(self, value: str, other: Any = None): with caplog.at_level(logging.DEBUG): obj = TestClass.from_dict( {"type": "test_class", "value": "test"}, - dependencies={"test_class.other": mock_other}, + dependencies={"test_class": {"other": mock_other}}, ) assert obj.value == "test" @@ -105,9 +105,11 @@ def __init__( obj = TestClass.from_dict( {"type": "test_class", "value": "test"}, dependencies={ - "test_class.client": mock_client, - "test_class.logger": mock_logger, - "test_class.other": mock_other, + "test_class": { + "client": mock_client, + "logger": mock_logger, + "other": mock_other, + } }, ) @@ -136,7 +138,7 @@ def __init__(self, value: str, client: Any = None): with caplog.at_level(logging.DEBUG): obj = TestClass.from_dict( {"type": "test_class", "value": "test"}, - dependencies={"test_class.client": mock_client}, + dependencies={"test_class": {"client": mock_client}}, ) assert obj.value == "test" @@ -184,7 +186,7 @@ def __init__(self, value: str, number: int, client: Any = None): assert "client" not in data # Excluded from serialization # Deserialize with dependency injection - restored = TestClass.from_dict(data, dependencies={"test_class.client": mock_client}) + restored = TestClass.from_dict(data, dependencies={"test_class": {"client": mock_client}}) assert restored.value == "test" assert restored.number == 42 assert restored.client == mock_client diff --git a/python/packages/core/tests/core/test_threads.py b/python/packages/core/tests/core/test_threads.py index c04cab577e7..80495017891 100644 --- a/python/packages/core/tests/core/test_threads.py +++ b/python/packages/core/tests/core/test_threads.py @@ -224,11 +224,15 @@ async def test_deserialize_with_existing_store(self) -> None: """Test _deserialize with existing message store.""" store = MockChatMessageStore() thread = AgentThread(message_store=store) - serialized_data: dict[str, Any] = {"service_thread_id": None, "chat_message_store_state": {"messages": []}} + serialized_data: dict[str, Any] = { + "service_thread_id": None, + "chat_message_store_state": {"messages": [ChatMessage(role="user", text="test")]}, + } await thread.update_from_thread_state(serialized_data) - assert store._deserialize_calls == 1 # pyright: ignore[reportPrivateUsage] + assert store._messages + assert store._messages[0].text == "test" async def test_serialize_with_service_thread_id(self) -> None: """Test serialize with service_thread_id.""" @@ -268,6 +272,23 @@ async def test_serialize_with_kwargs(self) -> None: assert store._serialize_calls == 1 # pyright: ignore[reportPrivateUsage] + async def test_serialize_round_trip_messages(self, sample_messages: list[ChatMessage]) -> None: + """Test a roundtrip of the serialization.""" + store = ChatMessageStore(sample_messages) + thread = AgentThread(message_store=store) + new_thread = await AgentThread.deserialize(await thread.serialize()) + assert new_thread.message_store is not None + new_messages = await new_thread.message_store.list_messages() + assert len(new_messages) == len(sample_messages) + assert {new.text for new in new_messages} == {orig.text for orig in sample_messages} + + async def test_serialize_round_trip_thread_id(self) -> None: + """Test a roundtrip of the serialization.""" + thread = AgentThread(service_thread_id="test-1234") + new_thread = await AgentThread.deserialize(await thread.serialize()) + assert new_thread.message_store is None + assert new_thread.service_thread_id == "test-1234" + class TestChatMessageList: """Test cases for ChatMessageStore class.""" @@ -377,7 +398,7 @@ def test_init_with_service_thread_id(self) -> None: def test_init_with_chat_message_store_state(self) -> None: """Test AgentThreadState initialization with chat_message_store_state.""" store_data: dict[str, Any] = {"messages": []} - state = AgentThreadState.model_validate({"chat_message_store_state": store_data}) + state = AgentThreadState.from_dict({"chat_message_store_state": store_data}) assert state.service_thread_id is None assert state.chat_message_store_state.messages == [] @@ -385,9 +406,7 @@ def test_init_with_chat_message_store_state(self) -> None: def test_init_with_both(self) -> None: """Test AgentThreadState initialization with both parameters.""" store_data: dict[str, Any] = {"messages": []} - with pytest.raises( - AgentThreadException, match="Only one of service_thread_id or chat_message_store_state may be set" - ): + with pytest.raises(AgentThreadException): AgentThreadState(service_thread_id="test-conv-123", chat_message_store_state=store_data) def test_init_defaults(self) -> None: diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 5a587c0b4e1..e2cf6b8d3d3 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -299,6 +299,48 @@ class WrongModel(BaseModel): await invalid_args_test.invoke(arguments=wrong_args) +def test_ai_function_serialization(): + """Test AIFunction serialization and deserialization.""" + + def serialize_test(x: int, y: int) -> int: + """A function for testing serialization.""" + return x - y + + serialize_test_ai_function = ai_function(name="serialize_test", description="A test tool for serialization")( + serialize_test + ) + + # Serialize to dict + tool_dict = serialize_test_ai_function.to_dict() + assert tool_dict["type"] == "ai_function" + assert tool_dict["name"] == "serialize_test" + assert tool_dict["description"] == "A test tool for serialization" + assert tool_dict["input_model"] == { + "properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}}, + "required": ["x", "y"], + "title": "serialize_test_input", + "type": "object", + } + + # Deserialize from dict + restored_tool = AIFunction.from_dict(tool_dict, dependencies={"ai_function": {"func": serialize_test}}) + assert isinstance(restored_tool, AIFunction) + assert restored_tool.name == "serialize_test" + assert restored_tool.description == "A test tool for serialization" + assert restored_tool.parameters() == serialize_test_ai_function.parameters() + assert restored_tool(10, 4) == 6 + + # Deserialize from dict with instance name + restored_tool_2 = AIFunction.from_dict( + tool_dict, dependencies={"ai_function": {"name:serialize_test": {"func": serialize_test}}} + ) + assert isinstance(restored_tool_2, AIFunction) + assert restored_tool_2.name == "serialize_test" + assert restored_tool_2.description == "A test tool for serialization" + assert restored_tool_2.parameters() == serialize_test_ai_function.parameters() + assert restored_tool_2(10, 4) == 6 + + # region HostedCodeInterpreterTool and _parse_inputs @@ -747,13 +789,14 @@ async def mock_get_response(self, messages, **kwargs): # Execute result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool]) - # Verify: should return 2 messages - function call and approval request + # Verify: should return 1 message with function call and approval request from agent_framework import FunctionApprovalRequestContent - assert len(result.messages) == 2 + assert len(result.messages) == 1 + assert len(result.messages[0].contents) == 2 assert isinstance(result.messages[0].contents[0], FunctionCallContent) - assert isinstance(result.messages[1].contents[0], FunctionApprovalRequestContent) - assert result.messages[1].contents[0].function_call.name == "requires_approval_tool" + assert isinstance(result.messages[0].contents[1], FunctionApprovalRequestContent) + assert result.messages[0].contents[1].function_call.name == "requires_approval_tool" async def test_non_streaming_two_functions_both_no_approval(): @@ -838,16 +881,17 @@ async def mock_get_response(self, messages, **kwargs): # Execute result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool]) - # Verify: should return 2 messages - function calls and approval requests + # Verify: should return 1 message with function calls and approval requests from agent_framework import FunctionApprovalRequestContent - assert len(result.messages) == 2 - assert len(result.messages[0].contents) == 2 # Both function calls - assert all(isinstance(c, FunctionCallContent) for c in result.messages[0].contents) - assert len(result.messages[1].contents) == 2 # Both approval requests - assert all(isinstance(c, FunctionApprovalRequestContent) for c in result.messages[1].contents) - assert result.messages[1].contents[0].function_call.name == "requires_approval_tool" - assert result.messages[1].contents[1].function_call.name == "requires_approval_tool" + assert len(result.messages) == 1 + assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests + function_calls = [c for c in result.messages[0].contents if isinstance(c, FunctionCallContent)] + approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)] + assert len(function_calls) == 2 + assert len(approval_requests) == 2 + assert approval_requests[0].function_call.name == "requires_approval_tool" + assert approval_requests[1].function_call.name == "requires_approval_tool" async def test_non_streaming_two_functions_mixed_approval(): @@ -886,10 +930,10 @@ async def mock_get_response(self, messages, **kwargs): # Verify: should return approval requests for both (when one needs approval, all are sent for approval) from agent_framework import FunctionApprovalRequestContent - assert len(result.messages) == 2 - assert len(result.messages[0].contents) == 2 # Both function calls - assert len(result.messages[1].contents) == 2 # Both approval requests - assert all(isinstance(c, FunctionApprovalRequestContent) for c in result.messages[1].contents) + assert len(result.messages) == 1 + assert len(result.messages[0].contents) == 4 # 2 function calls + 2 approval requests + approval_requests = [c for c in result.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)] + assert len(approval_requests) == 2 async def test_streaming_single_function_no_approval(): @@ -974,7 +1018,7 @@ async def mock_get_streaming_response(self, messages, **kwargs): assert len(updates) == 2 assert isinstance(updates[0].contents[0], FunctionCallContent) - assert updates[1].role == Role.TOOL + assert updates[1].role == Role.ASSISTANT assert isinstance(updates[1].contents[0], FunctionApprovalRequestContent) @@ -1069,8 +1113,8 @@ async def mock_get_streaming_response(self, messages, **kwargs): assert len(updates) == 3 assert isinstance(updates[0].contents[0], FunctionCallContent) assert isinstance(updates[1].contents[0], FunctionCallContent) - # Tool update with both approval requests - assert updates[2].role == Role.TOOL + # Assistant update with both approval requests + assert updates[2].role == Role.ASSISTANT assert len(updates[2].contents) == 2 assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents) @@ -1116,7 +1160,7 @@ async def mock_get_streaming_response(self, messages, **kwargs): assert len(updates) == 3 assert isinstance(updates[0].contents[0], FunctionCallContent) assert isinstance(updates[1].contents[0], FunctionCallContent) - # Tool update with both approval requests - assert updates[2].role == Role.TOOL + # Assistant update with both approval requests + assert updates[2].role == Role.ASSISTANT assert len(updates[2].contents) == 2 assert all(isinstance(c, FunctionApprovalRequestContent) for c in updates[2].contents) diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 90947dd4371..be1a059b58b 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -193,6 +193,18 @@ def test_openai_assistants_client_init_with_default_headers(openai_unit_test_env assert chat_client.client.default_headers[key] == value +def test_openai_assistants_client_instructions_sent_once(mock_async_openai: MagicMock) -> None: + """Ensure instructions are only included once for OpenAI Assistants requests.""" + chat_client = create_test_openai_assistants_client(mock_async_openai) + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = chat_client.prepare_messages([ChatMessage(role=Role.USER, text="Hello")], chat_options) + run_options, _ = chat_client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert run_options.get("instructions") == instructions + + async def test_openai_assistants_client_get_assistant_id_or_create_existing_assistant( mock_async_openai: MagicMock, ) -> None: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index d1590913118..63db8c071e8 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json import os from typing import Annotated from unittest.mock import MagicMock, patch @@ -99,6 +100,18 @@ def test_init_base_url_from_settings_env() -> None: assert str(client.client.base_url) == "https://custom-openai-endpoint.com/v1/" +def test_openai_chat_client_instructions_sent_once(openai_unit_test_env: dict[str, str]) -> None: + """Ensure instructions are only included once for OpenAI chat requests.""" + client = OpenAIChatClient() + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) + request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert json.dumps(request_options).count(instructions) == 1 + + @pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): 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 6be7140ef03..3087e09fae9 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -2,6 +2,7 @@ import asyncio import base64 +import json import os from typing import Annotated from unittest.mock import MagicMock, patch @@ -133,6 +134,18 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None: assert openai_responses_client.client.default_headers[key] == value +def test_openai_responses_client_instructions_sent_once(openai_unit_test_env: dict[str, str]) -> None: + """Ensure instructions are only included once for OpenAI Responses requests.""" + client = OpenAIResponsesClient() + instructions = "You are a helpful assistant." + chat_options = ChatOptions(instructions=instructions) + + prepared_messages = client.prepare_messages([ChatMessage(role="user", text="Hello")], chat_options) + request_options = client._prepare_options(prepared_messages, chat_options) # type: ignore[reportPrivateUsage] + + assert json.dumps(request_options).count(instructions) == 1 + + @pytest.mark.parametrize("exclude_list", [["OPENAI_RESPONSES_MODEL_ID"]], indirect=True) def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None: with pytest.raises(ServiceInitializationError): @@ -426,7 +439,7 @@ async def test_get_streaming_response_with_all_parameters() -> None: instructions="Stream response test", max_tokens=50, parallel_tool_calls=False, - model="gpt-4", + model_id="gpt-4", previous_response_id="stream-prev-123", reasoning={"mode": "stream"}, service_tier="default", 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 new file mode 100644 index 00000000000..8124f6253d4 --- /dev/null +++ b/python/packages/core/tests/workflow/test_agent_executor_tool_calls.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for AgentExecutor handling of tool calls and results in streaming mode.""" + +from collections.abc import AsyncIterable +from typing import Any + +from agent_framework import ( + AgentExecutor, + AgentRunResponse, + AgentRunResponseUpdate, + AgentRunUpdateEvent, + AgentThread, + BaseAgent, + ChatMessage, + FunctionCallContent, + FunctionResultContent, + Role, + TextContent, + WorkflowBuilder, +) + + +class _ToolCallingAgent(BaseAgent): + """Mock agent that simulates tool calls and results in streaming mode.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + async def run( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AgentRunResponse: + """Non-streaming run - not used in this test.""" + return AgentRunResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="done")]) + + async def run_stream( + self, + messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None, + *, + thread: AgentThread | None = None, + **kwargs: Any, + ) -> AsyncIterable[AgentRunResponseUpdate]: + """Simulate streaming with tool calls and results.""" + # First update: some text + yield AgentRunResponseUpdate( + contents=[TextContent(text="Let me search for that...")], + role=Role.ASSISTANT, + ) + + # Second update: tool call (no text!) + yield AgentRunResponseUpdate( + contents=[ + FunctionCallContent( + call_id="call_123", + name="search", + arguments={"query": "weather"}, + ) + ], + role=Role.ASSISTANT, + ) + + # Third update: tool result (no text!) + yield AgentRunResponseUpdate( + contents=[ + FunctionResultContent( + call_id="call_123", + result={"temperature": 72, "condition": "sunny"}, + ) + ], + role=Role.TOOL, + ) + + # Fourth update: final text response + yield AgentRunResponseUpdate( + contents=[TextContent(text="The weather is sunny, 72°F.")], + role=Role.ASSISTANT, + ) + + +async def test_agent_executor_emits_tool_calls_in_streaming_mode() -> None: + """Test that AgentExecutor emits updates containing FunctionCallContent and FunctionResultContent.""" + # Arrange + agent = _ToolCallingAgent(id="tool_agent", name="ToolAgent") + agent_exec = AgentExecutor(agent, id="tool_exec") + + workflow = WorkflowBuilder().set_start_executor(agent_exec).build() + + # Act: run in streaming mode + events: list[AgentRunUpdateEvent] = [] + async for event in workflow.run_stream("What's the weather?"): + if isinstance(event, AgentRunUpdateEvent): + events.append(event) + + # Assert: we should receive 4 events (text, function call, function result, text) + assert len(events) == 4, f"Expected 4 events, got {len(events)}" + + # First event: text update + assert events[0].data is not None + assert isinstance(events[0].data.contents[0], TextContent) + assert "Let me search" in events[0].data.contents[0].text + + # Second event: function call + assert events[1].data is not None + assert isinstance(events[1].data.contents[0], FunctionCallContent) + func_call = events[1].data.contents[0] + assert func_call.call_id == "call_123" + assert func_call.name == "search" + + # Third event: function result + assert events[2].data is not None + assert isinstance(events[2].data.contents[0], FunctionResultContent) + func_result = events[2].data.contents[0] + assert func_result.call_id == "call_123" + + # Fourth event: final text + assert events[3].data is not None + assert isinstance(events[3].data.contents[0], TextContent) + assert "sunny" in events[3].data.contents[0].text diff --git a/python/packages/core/tests/workflow/test_checkpoint_decode.py b/python/packages/core/tests/workflow/test_checkpoint_decode.py index 1947b3fb414..08c10aa9a96 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_decode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_decode.py @@ -3,7 +3,7 @@ from dataclasses import dataclass # noqa: I001 from typing import Any, cast -from agent_framework._workflows._executor import RequestInfoMessage, RequestResponse +from agent_framework._workflows._request_info_executor import RequestInfoMessage, RequestResponse from agent_framework._workflows._runner_context import ( # type: ignore _decode_checkpoint_value, # type: ignore _encode_checkpoint_value, # type: ignore diff --git a/python/packages/core/tests/workflow/test_magentic.py b/python/packages/core/tests/workflow/test_magentic.py index bb6984cc749..b52449a928c 100644 --- a/python/packages/core/tests/workflow/test_magentic.py +++ b/python/packages/core/tests/workflow/test_magentic.py @@ -344,9 +344,10 @@ async def test_magentic_checkpoint_resume_round_trip(): assert orchestrator._context is not None # type: ignore[reportPrivateUsage] assert orchestrator._context.chat_history # type: ignore[reportPrivateUsage] - assert orchestrator._context.chat_history[0].text == task_text # type: ignore[reportPrivateUsage] assert orchestrator._task_ledger is not None # type: ignore[reportPrivateUsage] assert manager2.task_ledger is not None + # Initial message should be the task ledger plan + assert orchestrator._context.chat_history[0].text == orchestrator._task_ledger.text # type: ignore[reportPrivateUsage] class _DummyExec(Executor): @@ -690,3 +691,47 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames(): responses={req_event.request_id: MagenticPlanReviewReply(decision=MagenticPlanReviewDecision.APPROVE)}, ): pass + + +class NotProgressingManager(MagenticManagerBase): + """ + A manager that never marks progress being made, to test stall/reset limits. + """ + + async def plan(self, magentic_context: MagenticContext) -> ChatMessage: + return ChatMessage(role=Role.ASSISTANT, text="ledger") + + async def replan(self, magentic_context: MagenticContext) -> ChatMessage: + return ChatMessage(role=Role.ASSISTANT, text="re-ledger") + + async def create_progress_ledger(self, magentic_context: MagenticContext) -> MagenticProgressLedger: + return MagenticProgressLedger( + is_request_satisfied=MagenticProgressLedgerItem(reason="r", answer=False), + is_in_loop=MagenticProgressLedgerItem(reason="r", answer=True), + is_progress_being_made=MagenticProgressLedgerItem(reason="r", answer=False), + next_speaker=MagenticProgressLedgerItem(reason="r", answer="agentA"), + instruction_or_question=MagenticProgressLedgerItem(reason="r", answer="done"), + ) + + async def prepare_final_answer(self, magentic_context: MagenticContext) -> ChatMessage: + return ChatMessage(role=Role.ASSISTANT, text="final") + + +async def test_magentic_stall_and_reset_successfully(): + manager = NotProgressingManager(max_round_count=10, max_stall_count=0, max_reset_count=1) + + wf = MagenticBuilder().participants(agentA=_DummyExec("agentA")).with_standard_manager(manager).build() + + events: list[WorkflowEvent] = [] + async for ev in wf.run_stream("test limits"): + events.append(ev) + + idle_status = next( + (e for e in events if isinstance(e, WorkflowStatusEvent) and e.state == WorkflowRunState.IDLE), None + ) + assert idle_status is not None + output_event = next((e for e in events if isinstance(e, WorkflowOutputEvent)), None) + assert output_event is not None + assert isinstance(output_event.data, ChatMessage) + assert output_event.data.text is not None + assert output_event.data.text == "re-ledger" diff --git a/python/packages/core/tests/workflow/test_request_info_executor_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_executor_rehydrate.py index dee1a30c25e..15d0d7d7aff 100644 --- a/python/packages/core/tests/workflow/test_request_info_executor_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_executor_rehydrate.py @@ -6,17 +6,19 @@ from typing import Any from agent_framework._workflows._checkpoint import CheckpointStorage, WorkflowCheckpoint +from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary from agent_framework._workflows._events import RequestInfoEvent, WorkflowEvent -from agent_framework._workflows._executor import ( +from agent_framework._workflows._request_info_executor import ( PendingRequestDetails, + PendingRequestSnapshot, RequestInfoExecutor, RequestInfoMessage, RequestResponse, ) -from agent_framework._workflows._runner_context import ( # type: ignore +from agent_framework._workflows._runner_context import ( CheckpointState, Message, - _encode_checkpoint_value, + _encode_checkpoint_value, # type: ignore ) from agent_framework._workflows._shared_state import SharedState from agent_framework._workflows._workflow_context import WorkflowContext @@ -85,6 +87,12 @@ async def get_checkpoint_state(self) -> CheckpointState: # pragma: no cover - u async def set_checkpoint_state(self, state: CheckpointState) -> None: # pragma: no cover - unused pass + def set_streaming(self, streaming: bool) -> None: # pragma: no cover - unused + pass + + def is_streaming(self) -> bool: # pragma: no cover - unused + return False + @dataclass(kw_only=True) class SimpleApproval(RequestInfoMessage): @@ -109,30 +117,18 @@ async def test_rehydrate_falls_back_when_request_type_missing() -> None: This simulates resuming a workflow where the HumanApprovalRequest class is unavailable in the current process (e.g., defined in __main__ during the original run). """ - request_id = "request-123" - snapshot = { - "request_id": request_id, - "source_executor_id": "review_gateway", - "request_type": "nonexistent.module:MissingRequest", - "summary": "...", - "details": { + snapshot = PendingRequestSnapshot( + request_id=request_id, + source_executor_id="review_gateway", + request_type="nonexistent.module:MissingRequest", + request_as_json_safe_dict={ "request_id": request_id, - "prompt": "Review draft", - "draft": "Draft text", - "iteration": 2, }, - } - - shared_state = SharedState() - async with shared_state.hold(): - await shared_state.set_within_hold( - PENDING_STATE_KEY, - {request_id: snapshot}, - ) + ) - runner_ctx = _StubRunnerContext({"pending_requests": {request_id: snapshot}}) - ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], shared_state, runner_ctx) + runner_ctx = _StubRunnerContext({PENDING_STATE_KEY: {request_id: snapshot}}) + ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), runner_ctx) executor = RequestInfoExecutor(id="request_info") @@ -141,31 +137,21 @@ async def test_rehydrate_falls_back_when_request_type_missing() -> None: assert event is not None assert event.request_id == request_id assert isinstance(event.data, RequestInfoMessage) - assert getattr(event.data, "prompt", None) == "Review draft" - assert getattr(event.data, "iteration", None) == 2 async def test_has_pending_request_detects_snapshot() -> None: - request_id = "req-pending" - snapshot = { - "request_id": request_id, - "source_executor_id": "review_gateway", - "details": { + request_id = "request-123" + snapshot = PendingRequestSnapshot( + request_id=request_id, + source_executor_id="review_gateway", + request_type="nonexistent.module:MissingRequest", + request_as_json_safe_dict={ "request_id": request_id, - "prompt": "Review", - "draft": "Draft", }, - } - - shared_state = SharedState() - async with shared_state.hold(): - await shared_state.set_within_hold( - PENDING_STATE_KEY, - {request_id: snapshot}, - ) + ) - runner_ctx = _StubRunnerContext({"pending_requests": {request_id: snapshot}}) - ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], shared_state, runner_ctx) + runner_ctx = _StubRunnerContext({PENDING_STATE_KEY: {request_id: snapshot}}) + ctx: WorkflowContext[Any] = WorkflowContext("request_info", ["workflow"], SharedState(), runner_ctx) executor = RequestInfoExecutor(id="request_info") @@ -221,7 +207,12 @@ def test_pending_requests_from_checkpoint_and_summary() -> None: iteration_count=1, ) - pending = RequestInfoExecutor.pending_requests_from_checkpoint(checkpoint) + summary = get_checkpoint_summary(checkpoint) + assert summary.checkpoint_id == "cp-1" + assert summary.status == "awaiting request response" + assert summary.pending_requests[0].request_id == "req-42" + + pending = summary.pending_requests assert len(pending) == 1 entry = pending[0] assert isinstance(entry, PendingRequestDetails) @@ -231,11 +222,6 @@ def test_pending_requests_from_checkpoint_and_summary() -> None: assert entry.iteration == 3 assert entry.original_request is not None - summary = RequestInfoExecutor.checkpoint_summary(checkpoint) - assert summary.checkpoint_id == "cp-1" - assert summary.status == "awaiting human response" - assert summary.pending_requests[0].request_id == "req-42" - def test_snapshot_state_serializes_non_json_payloads() -> None: executor = RequestInfoExecutor(id="request_info") @@ -305,13 +291,10 @@ async def test_run_persists_pending_requests_in_runner_state() -> None: await executor.execute(approval, ctx.source_executor_ids, shared_state, runner_ctx) # Runner state should include both pending snapshot and serialized request events - assert "pending_requests" in runner_ctx._state # pyright: ignore[reportPrivateUsage] - assert approval.request_id in runner_ctx._state["pending_requests"] # pyright: ignore[reportPrivateUsage] - assert "request_events" in runner_ctx._state # pyright: ignore[reportPrivateUsage] - assert approval.request_id in runner_ctx._state["request_events"] # pyright: ignore[reportPrivateUsage] + assert PENDING_STATE_KEY in runner_ctx._state # pyright: ignore[reportPrivateUsage] + assert approval.request_id in runner_ctx._state[PENDING_STATE_KEY] # pyright: ignore[reportPrivateUsage] response_ctx: WorkflowContext[None] = WorkflowContext("request_info", ["source"], shared_state, runner_ctx) await executor.handle_response("approved", approval.request_id, response_ctx) # type: ignore - assert runner_ctx._state["pending_requests"] == {} # pyright: ignore[reportPrivateUsage] - assert runner_ctx._state.get("request_events", {}).get(approval.request_id) is None # pyright: ignore[reportPrivateUsage] + assert runner_ctx._state[PENDING_STATE_KEY] == {} # pyright: ignore[reportPrivateUsage] diff --git a/python/packages/core/tests/workflow/test_workflow_states.py b/python/packages/core/tests/workflow/test_workflow_states.py index 1f6adba134c..1c4ba561d59 100644 --- a/python/packages/core/tests/workflow/test_workflow_states.py +++ b/python/packages/core/tests/workflow/test_workflow_states.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. from dataclasses import dataclass -from typing import Any import pytest from typing_extensions import Never @@ -25,7 +24,6 @@ WorkflowStatusEvent, handler, ) -from agent_framework import WorkflowContext as WFContext class FailingExecutor(Executor): @@ -182,39 +180,3 @@ def __init__(self, id: str, prompt: str, draft: str) -> None: @handler async def ask(self, _: str, ctx: WorkflowContext[SnapshotRequest]) -> None: # pragma: no cover - simple helper await ctx.send_message(SnapshotRequest(prompt=self._prompt, draft=self._draft, iteration=1)) - - -async def test_request_info_executor_tracks_pending_requests_via_shared_state(): - prompt = "Review the launch copy" - draft = "Limited edition grinder now $249" - requester = SnapshotRequester(id="snapshot_req", prompt=prompt, draft=draft) - request_info = RequestInfoExecutor(id="request_info") - - wf = WorkflowBuilder().set_start_executor(requester).add_edge(requester, request_info).build() - - events = [event async for event in wf.run_stream("start")] - assert any(isinstance(event, RequestInfoEvent) for event in events) - - pending_map: dict[str, Any] = await wf._shared_state.get(RequestInfoExecutor._PENDING_SHARED_STATE_KEY) # type: ignore[reportPrivateUsage] - assert isinstance(pending_map, dict) - assert len(pending_map) == 1 - snapshot: dict[str, Any] = next(iter(pending_map.values())) - assert snapshot["prompt"] == prompt - assert snapshot["draft"] == draft - assert snapshot.get("iteration") == 1 - - request_id: str = snapshot["request_id"] - - request_info_resume = RequestInfoExecutor(id="request_info_resume") - resume_context: WFContext[Any] = WFContext( - executor_id=request_info_resume.id, - source_executor_ids=[wf.__class__.__name__], - shared_state=wf._shared_state, # type: ignore[reportPrivateUsage] - runner_context=wf._runner_context, # type: ignore[reportPrivateUsage] - ) - - await request_info_resume.handle_response("approve", request_id, resume_context) - - updated_pending: dict[str, Any] = await wf._shared_state.get(RequestInfoExecutor._PENDING_SHARED_STATE_KEY) # type: ignore[reportPrivateUsage] - assert isinstance(updated_pending, dict) - assert request_id not in updated_pending diff --git a/python/packages/devui/README.md b/python/packages/devui/README.md index a76e1e5e4e5..d9a17392b25 100644 --- a/python/packages/devui/README.md +++ b/python/packages/devui/README.md @@ -47,7 +47,7 @@ devui ./agents --port 8080 # → API: http://localhost:8080/v1/* ``` -When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository to help you get started quickly. +When DevUI starts with no discovered entities, it displays a **sample entity gallery** with curated examples from the Agent Framework repository. You can download these samples, review them, and run them locally to get started quickly. ## Directory Structure @@ -78,21 +78,65 @@ devui ./agents --tracing framework ## OpenAI-Compatible API -For convenience, you can interact with the agents/workflows using the standard OpenAI API format. Just specify the `entity_id` in the `extra_body` field. This can be an `agent_id` or `workflow_id`. +For convenience, DevUI provides an OpenAI Responses backend API. This means you can run the backend and also use the OpenAI client sdk to connect to it. Use **agent/workflow name as the model**, and set streaming to `True` as needed. ```bash -# Standard OpenAI format +# Simple - use your entity name as the model curl -X POST http://localhost:8080/v1/responses \ -H "Content-Type: application/json" \ -d @- << 'EOF' { - "model": "agent-framework", - "input": "Hello world", - "extra_body": {"entity_id": "weather_agent"} + "model": "weather_agent", + "input": "Hello world" } +``` + +Or use the OpenAI Python SDK: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://localhost:8080/v1", + api_key="not-needed" # API key not required for local DevUI +) + +response = client.responses.create( + model="weather_agent", # Your agent/workflow name + input="What's the weather in Seattle?" +) + +# Extract text from response +print(response.output[0].content[0].text) +# Supports streaming with stream=True +``` +### Multi-turn Conversations + +Use the standard OpenAI `conversation` parameter for multi-turn conversations: + +```python +# Create a conversation +conversation = client.conversations.create( + metadata={"agent_id": "weather_agent"} +) + +# Use it across multiple turns +response1 = client.responses.create( + model="weather_agent", + input="What's the weather in Seattle?", + conversation=conversation.id +) + +response2 = client.responses.create( + model="weather_agent", + input="How about tomorrow?", + conversation=conversation.id # Continues the conversation! +) ``` +**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests. + ## CLI Options ```bash @@ -109,30 +153,100 @@ Options: ## Key Endpoints +## API Mapping + +Given that DevUI offers an OpenAI Responses API, it internally maps messages and events from Agent Framework to OpenAI Responses API events (in `_mapper.py`). For transparency, this mapping is shown below: + +| Agent Framework Content | OpenAI Event/Type | Status | +| ------------------------------- | ---------------------------------------- | -------- | +| `TextContent` | `response.output_text.delta` | Standard | +| `TextReasoningContent` | `response.reasoning_text.delta` | Standard | +| `FunctionCallContent` (initial) | `response.output_item.added` | Standard | +| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard | +| `FunctionResultContent` | `response.function_result.complete` | DevUI | +| `FunctionApprovalRequestContent`| `response.function_approval.requested` | DevUI | +| `FunctionApprovalResponseContent`| `response.function_approval.responded` | DevUI | +| `ErrorContent` | `error` | Standard | +| `UsageContent` | Final `Response.usage` field (not streamed) | Standard | +| `WorkflowEvent` | `response.workflow_event.complete` | DevUI | +| `DataContent` | `response.trace.complete` | DevUI | +| `UriContent` | `response.trace.complete` | DevUI | +| `HostedFileContent` | `response.trace.complete` | DevUI | +| `HostedVectorStoreContent` | `response.trace.complete` | DevUI | + +- **Standard** = OpenAI Responses API spec +- **DevUI** = Custom extensions for Agent Framework features (workflows, traces, function approvals) + +### OpenAI Responses API Compliance + +DevUI follows the OpenAI Responses API specification for maximum compatibility: + +**Standard OpenAI Types Used:** +- `ResponseOutputItemAddedEvent` - Output item notifications (function calls and results) +- `Response.usage` - Token usage (in final response, not streamed) +- All standard text, reasoning, and function call events + +**Custom DevUI Extensions:** +- `response.function_approval.requested` - Function approval requests (for interactive approval workflows) +- `response.function_approval.responded` - Function approval responses (user approval/rejection) +- `response.workflow_event.complete` - Agent Framework workflow events +- `response.trace.complete` - Execution traces and internal content (DataContent, UriContent, hosted files/stores) + +These custom extensions are clearly namespaced and can be safely ignored by standard OpenAI clients. + +### Entity Management + - `GET /v1/entities` - List discovered agents/workflows - `GET /v1/entities/{entity_id}/info` - Get detailed entity information -- `POST /v1/entities/add` - Add entity from URL (for gallery samples) -- `DELETE /v1/entities/{entity_id}` - Remove remote entity +- `POST /v1/entities/{entity_id}/reload` - Hot reload entity (for development) + +### Execution (OpenAI Responses API) + - `POST /v1/responses` - Execute agent/workflow (streaming or sync) + +### Conversations (OpenAI Standard) + +- `POST /v1/conversations` - Create conversation +- `GET /v1/conversations/{id}` - Get conversation +- `POST /v1/conversations/{id}` - Update conversation metadata +- `DELETE /v1/conversations/{id}` - Delete conversation +- `GET /v1/conversations?agent_id={id}` - List conversations _(DevUI extension)_ +- `POST /v1/conversations/{id}/items` - Add items to conversation +- `GET /v1/conversations/{id}/items` - List conversation items +- `GET /v1/conversations/{id}/items/{item_id}` - Get conversation item + +### Health + - `GET /health` - Health check -- `POST /v1/threads` - Create thread for agent (optional) -- `GET /v1/threads?agent_id={id}` - List threads for agent -- `GET /v1/threads/{thread_id}` - Get thread info -- `DELETE /v1/threads/{thread_id}` - Delete thread -- `GET /v1/threads/{thread_id}/messages` - Get thread messages + +## Security + +DevUI is designed as a **sample application for local development** and should not be exposed to untrusted networks or used in production environments. + +**Security features:** +- Only loads entities from local directories or in-memory registration +- No remote code execution capabilities +- Binds to localhost (127.0.0.1) by default +- All samples must be manually downloaded and reviewed before running + +**Best practices:** +- Never expose DevUI to the internet +- Review all agent/workflow code before running +- Only load entities from trusted sources +- Use `.env` files for sensitive credentials (never commit them) ## Implementation - **Discovery**: `agent_framework_devui/_discovery.py` - **Execution**: `agent_framework_devui/_executor.py` - **Message Mapping**: `agent_framework_devui/_mapper.py` -- **Session Management**: `agent_framework_devui/_session.py` +- **Conversations**: `agent_framework_devui/_conversations.py` - **API Server**: `agent_framework_devui/_server.py` - **CLI**: `agent_framework_devui/_cli.py` ## Examples -See `samples/` for working agent and workflow implementations. +See working implementations in `python/samples/getting_started/devui/` ## License diff --git a/python/packages/devui/agent_framework_devui/_conversations.py b/python/packages/devui/agent_framework_devui/_conversations.py new file mode 100644 index 00000000000..5b892c8f356 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/_conversations.py @@ -0,0 +1,473 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Conversation storage abstraction for OpenAI Conversations API. + +This module provides a clean abstraction layer for managing conversations +while wrapping AgentFramework's AgentThread underneath. +""" + +import time +import uuid +from abc import ABC, abstractmethod +from typing import Any, Literal, cast + +from agent_framework import AgentThread, ChatMessage +from openai.types.conversations import Conversation, ConversationDeletedResource +from openai.types.conversations.conversation_item import ConversationItem +from openai.types.conversations.message import Message +from openai.types.conversations.text_content import TextContent +from openai.types.responses import ( + ResponseFunctionToolCallItem, + ResponseFunctionToolCallOutputItem, + ResponseInputFile, + ResponseInputImage, +) + +# Type alias for OpenAI Message role literals +MessageRole = Literal["unknown", "user", "assistant", "system", "critic", "discriminator", "developer", "tool"] + + +class ConversationStore(ABC): + """Abstract base class for conversation storage. + + Provides OpenAI Conversations API interface while managing + AgentThread instances underneath. + """ + + @abstractmethod + def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation: + """Create a new conversation (wraps AgentThread creation). + + Args: + metadata: Optional metadata dict (e.g., {"agent_id": "weather_agent"}) + + Returns: + Conversation object with generated ID + """ + pass + + @abstractmethod + def get_conversation(self, conversation_id: str) -> Conversation | None: + """Retrieve conversation metadata. + + Args: + conversation_id: Conversation ID + + Returns: + Conversation object or None if not found + """ + pass + + @abstractmethod + def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation: + """Update conversation metadata. + + Args: + conversation_id: Conversation ID + metadata: New metadata dict + + Returns: + Updated Conversation object + + Raises: + ValueError: If conversation not found + """ + pass + + @abstractmethod + def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource: + """Delete conversation (including AgentThread). + + Args: + conversation_id: Conversation ID + + Returns: + ConversationDeletedResource object + + Raises: + ValueError: If conversation not found + """ + pass + + @abstractmethod + async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]: + """Add items to conversation (syncs to AgentThread.message_store). + + Args: + conversation_id: Conversation ID + items: List of conversation items to add + + Returns: + List of added ConversationItem objects + + Raises: + ValueError: If conversation not found + """ + pass + + @abstractmethod + async def list_items( + self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc" + ) -> tuple[list[ConversationItem], bool]: + """List conversation items from AgentThread.message_store. + + Args: + conversation_id: Conversation ID + limit: Maximum number of items to return + after: Cursor for pagination (item_id) + order: Sort order ("asc" or "desc") + + Returns: + Tuple of (items list, has_more boolean) + + Raises: + ValueError: If conversation not found + """ + pass + + @abstractmethod + def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: + """Get specific conversation item. + + Args: + conversation_id: Conversation ID + item_id: Item ID + + Returns: + ConversationItem or None if not found + """ + pass + + @abstractmethod + def get_thread(self, conversation_id: str) -> AgentThread | None: + """Get underlying AgentThread for execution (internal use). + + This is the critical method that allows the executor to get the + AgentThread for running agents with conversation context. + + Args: + conversation_id: Conversation ID + + Returns: + AgentThread object or None if not found + """ + pass + + @abstractmethod + def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: + """Filter conversations by metadata (e.g., agent_id). + + Args: + metadata_filter: Metadata key-value pairs to match + + Returns: + List of matching Conversation objects + """ + pass + + +class InMemoryConversationStore(ConversationStore): + """In-memory conversation storage wrapping AgentThread. + + This implementation stores conversations in memory with their + underlying AgentThread instances for execution. + """ + + def __init__(self) -> None: + """Initialize in-memory conversation storage. + + Storage structure maps conversation IDs to conversation data including + the underlying AgentThread, metadata, and cached ConversationItems. + """ + self._conversations: dict[str, dict[str, Any]] = {} + + # Item index for O(1) lookup: {conversation_id: {item_id: ConversationItem}} + self._item_index: dict[str, dict[str, ConversationItem]] = {} + + def create_conversation(self, metadata: dict[str, str] | None = None) -> Conversation: + """Create a new conversation with underlying AgentThread.""" + conv_id = f"conv_{uuid.uuid4().hex}" + created_at = int(time.time()) + + # Create AgentThread with default ChatMessageStore + thread = AgentThread() + + self._conversations[conv_id] = { + "id": conv_id, + "thread": thread, + "metadata": metadata or {}, + "created_at": created_at, + "items": [], + } + + # Initialize item index for this conversation + self._item_index[conv_id] = {} + + return Conversation(id=conv_id, object="conversation", created_at=created_at, metadata=metadata) + + def get_conversation(self, conversation_id: str) -> Conversation | None: + """Retrieve conversation metadata.""" + conv_data = self._conversations.get(conversation_id) + if not conv_data: + return None + + return Conversation( + id=conv_data["id"], + object="conversation", + created_at=conv_data["created_at"], + metadata=conv_data.get("metadata"), + ) + + def update_conversation(self, conversation_id: str, metadata: dict[str, str]) -> Conversation: + """Update conversation metadata.""" + conv_data = self._conversations.get(conversation_id) + if not conv_data: + raise ValueError(f"Conversation {conversation_id} not found") + + conv_data["metadata"] = metadata + + return Conversation( + id=conv_data["id"], + object="conversation", + created_at=conv_data["created_at"], + metadata=metadata, + ) + + def delete_conversation(self, conversation_id: str) -> ConversationDeletedResource: + """Delete conversation and its AgentThread.""" + if conversation_id not in self._conversations: + raise ValueError(f"Conversation {conversation_id} not found") + + del self._conversations[conversation_id] + # Cleanup item index + self._item_index.pop(conversation_id, None) + + return ConversationDeletedResource(id=conversation_id, object="conversation.deleted", deleted=True) + + async def add_items(self, conversation_id: str, items: list[dict[str, Any]]) -> list[ConversationItem]: + """Add items to conversation and sync to AgentThread.""" + conv_data = self._conversations.get(conversation_id) + if not conv_data: + raise ValueError(f"Conversation {conversation_id} not found") + + thread: AgentThread = conv_data["thread"] + + # Convert items to ChatMessages and add to thread + chat_messages = [] + for item in items: + # Simple conversion - assume text content for now + role = item.get("role", "user") + content = item.get("content", []) + text = content[0].get("text", "") if content else "" + + chat_msg = ChatMessage(role=role, contents=[{"type": "text", "text": text}]) + chat_messages.append(chat_msg) + + # Add messages to AgentThread + await thread.on_new_messages(chat_messages) + + # Create Message objects (ConversationItem is a Union - use concrete Message type) + conv_items: list[ConversationItem] = [] + for msg in chat_messages: + item_id = f"item_{uuid.uuid4().hex}" + + # Extract role - handle both string and enum + role_str = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles + + # Convert ChatMessage contents to OpenAI TextContent format + message_content = [] + for content_item in msg.contents: + if hasattr(content_item, "type") and content_item.type == "text": + # Extract text from TextContent object + text_value = getattr(content_item, "text", "") + message_content.append(TextContent(type="text", text=text_value)) + + # Create Message object (concrete type from ConversationItem union) + message = Message( + id=item_id, + type="message", # Required discriminator for union + role=role, + content=message_content, + status="completed", # Required field + ) + conv_items.append(message) + + # Cache items + conv_data["items"].extend(conv_items) + + # Update item index for O(1) lookup + if conversation_id not in self._item_index: + self._item_index[conversation_id] = {} + + for conv_item in conv_items: + if conv_item.id: # Guard against None + self._item_index[conversation_id][conv_item.id] = conv_item + + return conv_items + + async def list_items( + self, conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc" + ) -> tuple[list[ConversationItem], bool]: + """List conversation items from AgentThread message store. + + Converts AgentFramework ChatMessages to proper OpenAI ConversationItem types: + - Messages with text/images/files → Message + - Function calls → ResponseFunctionToolCallItem + - Function results → ResponseFunctionToolCallOutputItem + """ + conv_data = self._conversations.get(conversation_id) + if not conv_data: + raise ValueError(f"Conversation {conversation_id} not found") + + thread: AgentThread = conv_data["thread"] + + # Get messages from thread's message store + items: list[ConversationItem] = [] + if thread.message_store: + af_messages = await thread.message_store.list_messages() + + # Convert each AgentFramework ChatMessage to appropriate ConversationItem type(s) + for i, msg in enumerate(af_messages): + item_id = f"item_{i}" + role_str = msg.role.value if hasattr(msg.role, "value") else str(msg.role) + role = cast(MessageRole, role_str) # Safe: Agent Framework roles match OpenAI roles + + # Process each content item in the message + # A single ChatMessage may produce multiple ConversationItems + # (e.g., a message with both text and a function call) + message_contents: list[TextContent | ResponseInputImage | ResponseInputFile] = [] + function_calls = [] + function_results = [] + + for content in msg.contents: + content_type = getattr(content, "type", None) + + if content_type == "text": + # Text content for Message + text_value = getattr(content, "text", "") + message_contents.append(TextContent(type="text", text=text_value)) + + elif content_type == "data": + # Data content (images, files, PDFs) + uri = getattr(content, "uri", "") + media_type = getattr(content, "media_type", None) + + if media_type and media_type.startswith("image/"): + # Convert to ResponseInputImage + message_contents.append( + ResponseInputImage(type="input_image", image_url=uri, detail="auto") + ) + else: + # Convert to ResponseInputFile + # Extract filename from URI if possible + filename = None + if media_type == "application/pdf": + filename = "document.pdf" + + message_contents.append( + ResponseInputFile(type="input_file", file_url=uri, filename=filename) + ) + + elif content_type == "function_call": + # Function call - create separate ConversationItem + call_id = getattr(content, "call_id", None) + name = getattr(content, "name", "") + arguments = getattr(content, "arguments", "") + + if call_id and name: + function_calls.append( + ResponseFunctionToolCallItem( + id=f"{item_id}_call_{call_id}", + call_id=call_id, + name=name, + arguments=arguments, + type="function_call", + status="completed", + ) + ) + + elif content_type == "function_result": + # Function result - create separate ConversationItem + call_id = getattr(content, "call_id", None) + # Output is stored in additional_properties + output = "" + if hasattr(content, "additional_properties"): + output = content.additional_properties.get("output", "") + + if call_id: + function_results.append( + ResponseFunctionToolCallOutputItem( + id=f"{item_id}_result_{call_id}", + call_id=call_id, + output=output, + type="function_call_output", + status="completed", + ) + ) + + # Create ConversationItems based on what we found + # If message has text/images/files, create a Message item + if message_contents: + message = Message( + id=item_id, + type="message", + role=role, # type: ignore + content=message_contents, # type: ignore + status="completed", + ) + items.append(message) + + # Add function call items + items.extend(function_calls) + + # Add function result items + items.extend(function_results) + + # Apply pagination + if order == "desc": + items = items[::-1] + + start_idx = 0 + if after: + # Find the index after the cursor + for i, item in enumerate(items): + if item.id == after: + start_idx = i + 1 + break + + paginated_items = items[start_idx : start_idx + limit] + has_more = len(items) > start_idx + limit + + return paginated_items, has_more + + def get_item(self, conversation_id: str, item_id: str) -> ConversationItem | None: + """Get specific conversation item - O(1) lookup via index.""" + # Use index for O(1) lookup instead of linear search + conv_items = self._item_index.get(conversation_id) + if not conv_items: + return None + + return conv_items.get(item_id) + + def get_thread(self, conversation_id: str) -> AgentThread | None: + """Get AgentThread for execution - CRITICAL for agent.run_stream().""" + conv_data = self._conversations.get(conversation_id) + return conv_data["thread"] if conv_data else None + + def list_conversations_by_metadata(self, metadata_filter: dict[str, str]) -> list[Conversation]: + """Filter conversations by metadata (e.g., agent_id).""" + results = [] + for conv_data in self._conversations.values(): + conv_meta = conv_data.get("metadata", {}) + # Check if all filter items match + if all(conv_meta.get(k) == v for k, v in metadata_filter.items()): + results.append( + Conversation( + id=conv_data["id"], + object="conversation", + created_at=conv_data["created_at"], + metadata=conv_meta, + ) + ) + return results diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index 0ddc370d68a..175109c7a0a 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -4,7 +4,6 @@ from __future__ import annotations -import hashlib import importlib import importlib.util import logging @@ -13,7 +12,6 @@ from pathlib import Path from typing import Any -import httpx from dotenv import load_dotenv from .models._discovery_models import EntityInfo @@ -33,7 +31,6 @@ def __init__(self, entities_dir: str | None = None): self.entities_dir = entities_dir self._entities: dict[str, EntityInfo] = {} self._loaded_objects: dict[str, Any] = {} - self._remote_cache_dir = Path.home() / ".agent_framework_devui" / "remote_cache" async def discover_entities(self) -> list[EntityInfo]: """Scan for Agent Framework entities. @@ -73,6 +70,115 @@ def get_entity_object(self, entity_id: str) -> Any | None: """ return self._loaded_objects.get(entity_id) + async def load_entity(self, entity_id: str) -> Any: + """Load entity on-demand (lazy loading). + + This method implements lazy loading by importing the entity module only when needed. + In-memory entities are returned from cache immediately. + + Args: + entity_id: Entity identifier + + Returns: + Loaded entity object + + Raises: + ValueError: If entity not found or cannot be loaded + """ + # Check if already loaded (includes in-memory entities) + if entity_id in self._loaded_objects: + logger.debug(f"Entity {entity_id} already loaded (cache hit)") + return self._loaded_objects[entity_id] + + # Get entity metadata + entity_info = self._entities.get(entity_id) + if not entity_info: + raise ValueError(f"Entity {entity_id} not found in registry") + + # In-memory entities should never reach here (they're pre-loaded) + if entity_info.source == "in_memory": + raise ValueError(f"In-memory entity {entity_id} missing from loaded objects cache") + + logger.info(f"Lazy loading entity: {entity_id} (source: {entity_info.source})") + + # Load based on source - only directory and in-memory are supported + if entity_info.source == "directory": + entity_obj = await self._load_directory_entity(entity_id, entity_info) + else: + raise ValueError( + f"Unsupported entity source: {entity_info.source}. " + f"Only 'directory' and 'in_memory' sources are supported." + ) + + # Enrich metadata with actual entity data + # Don't pass entity_type if it's "unknown" - let inference determine the real type + enriched_info = await self.create_entity_info_from_object( + entity_obj, + entity_type=entity_info.type if entity_info.type != "unknown" else None, + source=entity_info.source, + ) + # IMPORTANT: Preserve the original entity_id (enrichment generates a new one) + enriched_info.id = entity_id + # Preserve the original path from sparse metadata + if "path" in entity_info.metadata: + enriched_info.metadata["path"] = entity_info.metadata["path"] + enriched_info.metadata["lazy_loaded"] = True + self._entities[entity_id] = enriched_info + + # Cache the loaded object + self._loaded_objects[entity_id] = entity_obj + logger.info(f"✅ Successfully loaded entity: {entity_id} (type: {enriched_info.type})") + + return entity_obj + + async def _load_directory_entity(self, entity_id: str, entity_info: EntityInfo) -> Any: + """Load entity from directory (imports module). + + Args: + entity_id: Entity identifier + entity_info: Entity metadata + + Returns: + Loaded entity object + """ + # Get directory path from metadata + dir_path = Path(entity_info.metadata.get("path", "")) + if not dir_path.exists(): # noqa: ASYNC240 + raise ValueError(f"Entity directory not found: {dir_path}") + + # Load .env if it exists + if dir_path.is_dir(): # noqa: ASYNC240 + self._load_env_for_entity(dir_path) + else: + self._load_env_for_entity(dir_path.parent) + + # Import the module + if dir_path.is_dir(): # noqa: ASYNC240 + # Directory-based entity - try different import patterns + import_patterns = [ + entity_id, + f"{entity_id}.agent", + f"{entity_id}.workflow", + ] + + for pattern in import_patterns: + module = self._load_module_from_pattern(pattern) + if module: + # Find entity in module - pass entity_id so registration uses correct ID + entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path)) + if entity_obj: + return entity_obj + + raise ValueError(f"No valid entity found in {dir_path}") + # File-based entity + module = self._load_module_from_file(dir_path, entity_id) + if module: + entity_obj = await self._find_entity_in_module(module, entity_id, str(dir_path)) + if entity_obj: + return entity_obj + + raise ValueError(f"No valid entity found in {dir_path}") + def list_entities(self) -> list[EntityInfo]: """List all discovered entities. @@ -81,6 +187,48 @@ def list_entities(self) -> list[EntityInfo]: """ return list(self._entities.values()) + def invalidate_entity(self, entity_id: str) -> None: + """Invalidate (clear cache for) an entity to enable hot reload. + + This removes the entity from the loaded objects cache and clears its module + from Python's sys.modules cache. The entity metadata remains, so it will be + reimported on next access. + + Args: + entity_id: Entity identifier to invalidate + """ + # Remove from loaded objects cache + if entity_id in self._loaded_objects: + del self._loaded_objects[entity_id] + logger.info(f"Cleared loaded object cache for: {entity_id}") + + # Clear from Python's module cache (including submodules) + keys_to_delete = [ + module_name + for module_name in sys.modules + if module_name == entity_id or module_name.startswith(f"{entity_id}.") + ] + for key in keys_to_delete: + del sys.modules[key] + logger.debug(f"Cleared module cache: {key}") + + # Reset lazy_loaded flag in metadata + entity_info = self._entities.get(entity_id) + if entity_info and "lazy_loaded" in entity_info.metadata: + entity_info.metadata["lazy_loaded"] = False + + logger.info(f"♻️ Entity invalidated: {entity_id} (will reload on next access)") + + def invalidate_all(self) -> None: + """Invalidate all cached entities. + + Useful for forcing a complete reload of all entities. + """ + entity_ids = list(self._loaded_objects.keys()) + for entity_id in entity_ids: + self.invalidate_entity(entity_id) + logger.info(f"Invalidated {len(entity_ids)} entities") + def register_entity(self, entity_id: str, entity_info: EntityInfo, entity_object: Any) -> None: """Register an entity with both metadata and object. @@ -116,16 +264,9 @@ async def create_entity_info_from_object( # Extract metadata with improved fallback naming name = getattr(entity_object, "name", None) if not name: - # In-memory entities: use ID with entity type prefix since no directory name available - entity_id_raw = getattr(entity_object, "id", None) - if entity_id_raw: - # Truncate UUID to first 8 characters for readability - short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw) - name = f"{entity_type.title()} {short_id}" - else: - # Fallback to class name with entity type - class_name = entity_object.__class__.__name__ - name = f"{entity_type.title()} {class_name}" + # In-memory entities: use class name as it's more readable than UUID + class_name = entity_object.__class__.__name__ + name = f"{entity_type.title()} {class_name}" description = getattr(entity_object, "description", "") # Generate entity ID using Agent Framework specific naming @@ -142,43 +283,27 @@ async def create_entity_info_from_object( middleware_list = None if entity_type == "agent": - # Try to get instructions - if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"): - instructions = entity_object.chat_options.instructions - - # Try to get model - check both chat_options and chat_client - if ( - hasattr(entity_object, "chat_options") - and hasattr(entity_object.chat_options, "model_id") - and entity_object.chat_options.model_id - ): - model = entity_object.chat_options.model_id - elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"): - model = entity_object.chat_client.model_id - - # Try to get chat client type - if hasattr(entity_object, "chat_client"): - chat_client_type = entity_object.chat_client.__class__.__name__ - - # Try to get context providers - if ( - hasattr(entity_object, "context_provider") - and entity_object.context_provider - and hasattr(entity_object.context_provider, "__class__") - ): - context_providers_list = [entity_object.context_provider.__class__.__name__] - - # Try to get middleware - if hasattr(entity_object, "middleware") and entity_object.middleware: - middleware_list = [] - for m in entity_object.middleware: - # Try multiple ways to get a good name for middleware - if hasattr(m, "__name__"): # Function or callable - middleware_list.append(m.__name__) - elif hasattr(m, "__class__"): # Class instance - middleware_list.append(m.__class__.__name__) - else: - middleware_list.append(str(m)) + from ._utils import extract_agent_metadata + + agent_meta = extract_agent_metadata(entity_object) + instructions = agent_meta["instructions"] + model = agent_meta["model"] + chat_client_type = agent_meta["chat_client_type"] + context_providers_list = agent_meta["context_providers"] + middleware_list = agent_meta["middleware"] + + # Log helpful info about agent capabilities (before creating EntityInfo) + if entity_type == "agent": + has_run_stream = hasattr(entity_object, "run_stream") + has_run = hasattr(entity_object, "run") + + if not has_run_stream and has_run: + logger.info( + f"Agent '{entity_id}' only has run() (non-streaming). " + "DevUI will automatically convert to streaming." + ) + elif not has_run_stream and not has_run: + logger.warning(f"Agent '{entity_id}' lacks both run() and run_stream() methods. May not work.") # Create EntityInfo with Agent Framework specifics return EntityInfo( @@ -206,7 +331,10 @@ async def create_entity_info_from_object( ) async def _scan_entities_directory(self, entities_dir: Path) -> None: - """Scan the entities directory for Agent Framework entities. + """Scan the entities directory for Agent Framework entities (lazy loading). + + This method scans the filesystem WITHOUT importing modules, creating sparse + metadata that will be enriched on-demand when entities are accessed. Args: entities_dir: Directory to scan for entities @@ -215,78 +343,120 @@ async def _scan_entities_directory(self, entities_dir: Path) -> None: logger.warning(f"Entities directory not found: {entities_dir}") return - logger.info(f"Scanning {entities_dir} for Agent Framework entities...") + logger.info(f"Scanning {entities_dir} for Agent Framework entities (lazy mode)...") # Add entities directory to Python path if not already there entities_dir_str = str(entities_dir) if entities_dir_str not in sys.path: sys.path.insert(0, entities_dir_str) - # Scan for directories and Python files + # Scan for directories and Python files WITHOUT importing for item in entities_dir.iterdir(): # noqa: ASYNC240 if item.name.startswith(".") or item.name == "__pycache__": continue - if item.is_dir(): - # Directory-based entity - await self._discover_entities_in_directory(item) + if item.is_dir() and self._looks_like_entity(item): + # Directory-based entity - create sparse metadata + self._register_sparse_entity(item) elif item.is_file() and item.suffix == ".py" and not item.name.startswith("_"): - # Single file entity - await self._discover_entities_in_file(item) + # Single file entity - create sparse metadata + self._register_sparse_file_entity(item) - async def _discover_entities_in_directory(self, dir_path: Path) -> None: - """Discover entities in a directory using module import. + def _looks_like_entity(self, dir_path: Path) -> bool: + """Check if directory contains an entity (without importing). Args: - dir_path: Directory containing entity - """ - entity_id = dir_path.name - logger.debug(f"Scanning directory: {entity_id}") + dir_path: Directory to check - try: - # Load environment variables for this entity first - self._load_env_for_entity(dir_path) + Returns: + True if directory appears to contain an entity + """ + return ( + (dir_path / "agent.py").exists() + or (dir_path / "workflow.py").exists() + or (dir_path / "__init__.py").exists() + ) - # Try different import patterns - import_patterns = [ - entity_id, # Direct module import - f"{entity_id}.agent", # agent.py submodule - f"{entity_id}.workflow", # workflow.py submodule - ] + def _detect_entity_type(self, dir_path: Path) -> str: + """Detect entity type from directory structure (without importing). - for pattern in import_patterns: - module = self._load_module_from_pattern(pattern) - if module: - entities_found = await self._find_entities_in_module(module, entity_id, str(dir_path)) - if entities_found: - logger.debug(f"Found {len(entities_found)} entities in {pattern}") - break + Uses filename conventions to determine entity type: + - workflow.py → "workflow" + - agent.py → "agent" + - both or neither → "unknown" - except Exception as e: - logger.warning(f"Error scanning directory {entity_id}: {e}") + Args: + dir_path: Directory to analyze - async def _discover_entities_in_file(self, file_path: Path) -> None: - """Discover entities in a single Python file. + Returns: + Entity type: "workflow", "agent", or "unknown" + """ + has_agent = (dir_path / "agent.py").exists() + has_workflow = (dir_path / "workflow.py").exists() + + if has_agent and has_workflow: + # Both files exist - ambiguous, mark as unknown + return "unknown" + if has_workflow: + return "workflow" + if has_agent: + return "agent" + # Has __init__.py but no specific file + return "unknown" + + def _register_sparse_entity(self, dir_path: Path) -> None: + """Register entity with sparse metadata (no import). Args: - file_path: Python file to scan + dir_path: Entity directory """ - try: - # Load environment variables for this entity's directory first - self._load_env_for_entity(file_path.parent) + entity_id = dir_path.name + entity_type = self._detect_entity_type(dir_path) - # Create module name from file path - base_name = file_path.stem + entity_info = EntityInfo( + id=entity_id, + name=entity_id.replace("_", " ").title(), + type=entity_type, + framework="agent_framework", + tools=[], # Sparse - will be populated on load + description="", # Sparse - will be populated on load + source="directory", + metadata={ + "path": str(dir_path), + "discovered": True, + "lazy_loaded": False, + }, + ) - # Load the module directly from file - module = self._load_module_from_file(file_path, base_name) - if module: - entities_found = await self._find_entities_in_module(module, base_name, str(file_path)) - if entities_found: - logger.debug(f"Found {len(entities_found)} entities in {file_path.name}") + self._entities[entity_id] = entity_info + logger.debug(f"Registered sparse entity: {entity_id} (type: {entity_type})") - except Exception as e: - logger.warning(f"Error scanning file {file_path}: {e}") + def _register_sparse_file_entity(self, file_path: Path) -> None: + """Register file-based entity with sparse metadata (no import). + + Args: + file_path: Entity Python file + """ + entity_id = file_path.stem + + # File-based entities are typically agents, but we can't know for sure without importing + entity_info = EntityInfo( + id=entity_id, + name=entity_id.replace("_", " ").title(), + type="unknown", # Will be determined on load + framework="agent_framework", + tools=[], + description="", + source="directory", + metadata={ + "path": str(file_path), + "discovered": True, + "lazy_loaded": False, + }, + ) + + self._entities[entity_id] = entity_info + logger.debug(f"Registered sparse file entity: {entity_id}") def _load_env_for_entity(self, entity_path: Path) -> bool: """Load .env file for an entity. @@ -378,19 +548,17 @@ def _load_module_from_file(self, file_path: Path, module_name: str) -> Any | Non logger.warning(f"Error loading module from {file_path}: {e}") return None - async def _find_entities_in_module(self, module: Any, base_id: str, module_path: str) -> list[str]: - """Find agent and workflow entities in a loaded module. + async def _find_entity_in_module(self, module: Any, entity_id: str, module_path: str) -> Any: + """Find agent or workflow entity in a loaded module. Args: module: Loaded Python module - base_id: Base identifier for entities + entity_id: Expected entity identifier to register with module_path: Path to module for metadata Returns: - List of entity IDs that were found and registered + Loaded entity object, or None if not found """ - entities_found = [] - # Look for explicit variable names first candidates = [ ("agent", getattr(module, "agent", None)), @@ -402,11 +570,12 @@ async def _find_entities_in_module(self, module: Any, base_id: str, module_path: continue if self._is_valid_entity(obj, obj_type): - # Pass source as "directory" for directory-discovered entities - await self._register_entity_from_object(obj, obj_type, module_path, source="directory") - entities_found.append(obj_type) + # Register with the correct entity_id (from directory name) + # Store the object directly in _loaded_objects so we can return it + self._loaded_objects[entity_id] = obj + return obj - return entities_found + return None def _is_valid_entity(self, obj: Any, expected_type: str) -> bool: """Check if object is a valid agent or workflow using duck typing. @@ -444,7 +613,9 @@ def _is_valid_agent(self, obj: Any) -> bool: pass # Fallback to duck typing for agent protocol - if hasattr(obj, "run_stream") and hasattr(obj, "id") and hasattr(obj, "name"): + # Agent must have either run_stream() or run() method, plus id and name + has_execution_method = hasattr(obj, "run_stream") or hasattr(obj, "run") + if has_execution_method and hasattr(obj, "id") and hasattr(obj, "name"): return True except (TypeError, AttributeError): @@ -482,13 +653,9 @@ async def _register_entity_from_object( # Extract metadata from the live object with improved fallback naming name = getattr(obj, "name", None) if not name: - entity_id_raw = getattr(obj, "id", None) - if entity_id_raw: - # Truncate UUID to first 8 characters for readability - short_id = str(entity_id_raw)[:8] if len(str(entity_id_raw)) > 8 else str(entity_id_raw) - name = f"{obj_type.title()} {short_id}" - else: - name = f"{obj_type.title()} {obj.__class__.__name__}" + # Use class name as it's more readable than UUID + class_name = obj.__class__.__name__ + name = f"{obj_type.title()} {class_name}" description = getattr(obj, "description", None) tools = await self._extract_tools_from_object(obj, obj_type) @@ -505,39 +672,14 @@ async def _register_entity_from_object( middleware_list = None if obj_type == "agent": - # Try to get instructions - if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "instructions"): - instructions = obj.chat_options.instructions - - # Try to get model - check both chat_options and chat_client - if hasattr(obj, "chat_options") and hasattr(obj.chat_options, "model_id") and obj.chat_options.model_id: - model = obj.chat_options.model_id - elif hasattr(obj, "chat_client") and hasattr(obj.chat_client, "model_id"): - model = obj.chat_client.model_id - - # Try to get chat client type - if hasattr(obj, "chat_client"): - chat_client_type = obj.chat_client.__class__.__name__ - - # Try to get context providers - if ( - hasattr(obj, "context_provider") - and obj.context_provider - and hasattr(obj.context_provider, "__class__") - ): - context_providers_list = [obj.context_provider.__class__.__name__] - - # Try to get middleware - if hasattr(obj, "middleware") and obj.middleware: - middleware_list = [] - for m in obj.middleware: - # Try multiple ways to get a good name for middleware - if hasattr(m, "__name__"): # Function or callable - middleware_list.append(m.__name__) - elif hasattr(m, "__class__"): # Class instance - middleware_list.append(m.__class__.__name__) - else: - middleware_list.append(str(m)) + from ._utils import extract_agent_metadata + + agent_meta = extract_agent_metadata(obj) + instructions = agent_meta["instructions"] + model = agent_meta["model"] + chat_client_type = agent_meta["chat_client_type"] + context_providers_list = agent_meta["context_providers"] + middleware_list = agent_meta["middleware"] entity_info = EntityInfo( id=entity_id, @@ -628,7 +770,7 @@ def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "dire source: Source of entity (directory, in_memory, remote) Returns: - Unique entity ID with format: {type}_{source}_{name}_{uuid8} + Unique entity ID with format: {type}_{source}_{name}_{uuid} """ import re @@ -644,179 +786,7 @@ def _generate_entity_id(self, entity: Any, entity_type: str, source: str = "dire else: base_name = "entity" - # Generate short UUID (8 chars = 4 billion combinations) - short_uuid = uuid.uuid4().hex[:8] - - return f"{entity_type}_{source}_{base_name}_{short_uuid}" - - async def fetch_remote_entity( - self, url: str, metadata: dict[str, Any] | None = None - ) -> tuple[EntityInfo | None, str | None]: - """Fetch and register entity from URL. - - Args: - url: URL to Python file containing entity - metadata: Additional metadata (source, sampleId, etc.) - - Returns: - Tuple of (EntityInfo if successful, error_message if failed) - """ - try: - normalized_url = self._normalize_url(url) - logger.info(f"Normalized URL: {normalized_url}") - - content = await self._fetch_url_content(normalized_url) - if not content: - error_msg = "Failed to fetch content from URL. The file may not exist or is not accessible." - logger.warning(error_msg) - return None, error_msg - - if not self._validate_python_syntax(content): - error_msg = "Invalid Python syntax in the file. Please check the file contains valid Python code." - logger.warning(error_msg) - return None, error_msg - - entity_object = await self._load_entity_from_content(content, url) - if not entity_object: - error_msg = ( - "No valid agent or workflow found in the file. " - "Make sure the file contains an 'agent' or 'workflow' variable." - ) - logger.warning(error_msg) - return None, error_msg - - entity_info = await self.create_entity_info_from_object( - entity_object, - entity_type=None, # Auto-detect - source="remote", - ) - - entity_info.source = metadata.get("source", "remote_gallery") if metadata else "remote_gallery" - entity_info.original_url = url - if metadata: - entity_info.metadata.update(metadata) - - self.register_entity(entity_info.id, entity_info, entity_object) + # Generate full UUID for guaranteed uniqueness + full_uuid = uuid.uuid4().hex - logger.info(f"Successfully added remote entity: {entity_info.id}") - return entity_info, None - - except Exception as e: - error_msg = f"Unexpected error: {e!s}" - logger.error(f"Error fetching remote entity from {url}: {e}", exc_info=True) - return None, error_msg - - def _normalize_url(self, url: str) -> str: - """Convert various Git hosting URLs to raw content URLs.""" - # GitHub: blob -> raw - if "github.com" in url and "/blob/" in url: - return url.replace("github.com", "raw.githubusercontent.com").replace("/blob/", "/") - - # GitLab: blob -> raw - if "gitlab.com" in url and "/-/blob/" in url: - return url.replace("/-/blob/", "/-/raw/") - - # Bitbucket: src -> raw - if "bitbucket.org" in url and "/src/" in url: - return url.replace("/src/", "/raw/") - - return url - - async def _fetch_url_content(self, url: str, max_size_mb: int = 10) -> str | None: - """Fetch content from URL with size and timeout limits.""" - try: - timeout = 30.0 # 30 second timeout - - async with httpx.AsyncClient(timeout=timeout) as client: - response = await client.get(url) - - if response.status_code != 200: - logger.warning(f"HTTP {response.status_code} for {url}") - return None - - # Check content length - content_length = response.headers.get("content-length") - if content_length and int(content_length) > max_size_mb * 1024 * 1024: - logger.warning(f"File too large: {content_length} bytes") - return None - - # Read with size limit - content = response.text - if len(content.encode("utf-8")) > max_size_mb * 1024 * 1024: - logger.warning("Content too large after reading") - return None - - return content - - except Exception as e: - logger.error(f"Error fetching {url}: {e}") - return None - - def _validate_python_syntax(self, content: str) -> bool: - """Validate that content is valid Python code.""" - try: - compile(content, "", "exec") - return True - except SyntaxError as e: - logger.warning(f"Python syntax error: {e}") - return False - - async def _load_entity_from_content(self, content: str, source_url: str) -> Any | None: - """Load entity object from Python content string using disk-based import. - - This method caches remote entities to disk and uses importlib for loading, - making it consistent with local entity discovery and avoiding exec() security warnings. - """ - try: - # Create cache directory if it doesn't exist - self._remote_cache_dir.mkdir(parents=True, exist_ok=True) - - # Generate a unique filename based on URL hash - url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:16] - module_name = f"remote_entity_{url_hash}" - cached_file = self._remote_cache_dir / f"{module_name}.py" - - # Write content to cache file - cached_file.write_text(content, encoding="utf-8") - logger.debug(f"Cached remote entity to {cached_file}") - - # Load module from cached file using importlib (same as local scanning) - module = self._load_module_from_file(cached_file, module_name) - if not module: - logger.warning(f"Failed to load module from cached file: {cached_file}") - return None - - # Look for agent or workflow objects in the loaded module - for name in dir(module): - if name.startswith("_"): - continue - - obj = getattr(module, name) - - # Check for explicitly named entities first - if name in ["agent", "workflow"] and self._is_valid_entity(obj, name): - return obj - - # Also check if any object looks like an agent/workflow - if self._is_valid_agent(obj) or self._is_valid_workflow(obj): - return obj - - return None - - except Exception as e: - logger.error(f"Error loading entity from content: {e}") - return None - - def remove_remote_entity(self, entity_id: str) -> bool: - """Remove a remote entity by ID.""" - if entity_id in self._entities: - entity_info = self._entities[entity_id] - if entity_info.source in ["remote_gallery", "remote"]: - del self._entities[entity_id] - if entity_id in self._loaded_objects: - del self._loaded_objects[entity_id] - logger.info(f"Removed remote entity: {entity_id}") - return True - logger.warning(f"Cannot remove local entity: {entity_id}") - return False - return False + return f"{entity_type}_{source}_{base_name}_{full_uuid}" diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index 73edfde74f1..6c02d91cf0f 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -5,12 +5,12 @@ import json import logging import os -import uuid from collections.abc import AsyncGenerator -from typing import Any, get_origin +from typing import Any -from agent_framework import AgentThread +from agent_framework import AgentProtocol +from ._conversations import ConversationStore, InMemoryConversationStore from ._discovery import EntityDiscovery from ._mapper import MessageMapper from ._tracing import capture_traces @@ -29,21 +29,26 @@ class EntityNotFoundError(Exception): class AgentFrameworkExecutor: """Executor for Agent Framework entities - agents and workflows.""" - def __init__(self, entity_discovery: EntityDiscovery, message_mapper: MessageMapper): + def __init__( + self, + entity_discovery: EntityDiscovery, + message_mapper: MessageMapper, + conversation_store: ConversationStore | None = None, + ): """Initialize Agent Framework executor. Args: entity_discovery: Entity discovery instance message_mapper: Message mapper instance + conversation_store: Optional conversation store (defaults to in-memory) """ self.entity_discovery = entity_discovery self.message_mapper = message_mapper self._setup_tracing_provider() self._setup_agent_framework_tracing() - # Minimal thread storage - no metadata needed - self.thread_storage: dict[str, AgentThread] = {} - self.agent_threads: dict[str, list[str]] = {} # agent_id -> thread_ids + # Use provided conversation store or default to in-memory + self.conversation_store = conversation_store or InMemoryConversationStore() def _setup_tracing_provider(self) -> None: """Set up our own TracerProvider so we can add processors.""" @@ -83,199 +88,6 @@ def _setup_agent_framework_tracing(self) -> None: else: logger.debug("ENABLE_OTEL not set, skipping observability setup") - # Thread Management Methods - def create_thread(self, agent_id: str) -> str: - """Create new thread for agent.""" - thread_id = f"thread_{uuid.uuid4().hex[:8]}" - thread = AgentThread() - - self.thread_storage[thread_id] = thread - - if agent_id not in self.agent_threads: - self.agent_threads[agent_id] = [] - self.agent_threads[agent_id].append(thread_id) - - return thread_id - - def get_thread(self, thread_id: str) -> AgentThread | None: - """Get AgentThread by ID.""" - return self.thread_storage.get(thread_id) - - def list_threads_for_agent(self, agent_id: str) -> list[str]: - """List thread IDs for agent.""" - return self.agent_threads.get(agent_id, []) - - def get_agent_for_thread(self, thread_id: str) -> str | None: - """Find which agent owns this thread.""" - for agent_id, thread_ids in self.agent_threads.items(): - if thread_id in thread_ids: - return agent_id - return None - - def delete_thread(self, thread_id: str) -> bool: - """Delete thread.""" - if thread_id not in self.thread_storage: - return False - - for _agent_id, thread_ids in self.agent_threads.items(): - if thread_id in thread_ids: - thread_ids.remove(thread_id) - break - - del self.thread_storage[thread_id] - return True - - async def get_thread_messages(self, thread_id: str) -> list[dict[str, Any]]: - """Get messages from a thread's message store, preserving all content types for UI display.""" - thread = self.get_thread(thread_id) - if not thread or not thread.message_store: - return [] - - try: - # Get AgentFramework ChatMessage objects from thread - af_messages = await thread.message_store.list_messages() - - ui_messages = [] - for i, af_msg in enumerate(af_messages): - # Extract role value (handle enum) - role = af_msg.role.value if hasattr(af_msg.role, "value") else str(af_msg.role) - - # Skip tool/function messages - only show user and assistant messages - if role not in ["user", "assistant"]: - continue - - # Extract all user-facing content (text, images, files, etc.) - display_contents = self._extract_display_contents(af_msg.contents) - - # Skip messages with no displayable content - if not display_contents: - continue - - # Extract usage information if present - usage_data = None - for content in af_msg.contents: - content_type = getattr(content, "type", None) - if content_type == "usage": - details = getattr(content, "details", None) - if details: - usage_data = { - "total_tokens": getattr(details, "total_token_count", 0) or 0, - "prompt_tokens": getattr(details, "input_token_count", 0) or 0, - "completion_tokens": getattr(details, "output_token_count", 0) or 0, - } - break - - ui_message = { - "id": af_msg.message_id or f"restored-{i}", - "role": role, - "contents": display_contents, - "timestamp": __import__("datetime").datetime.now().isoformat(), - "author_name": af_msg.author_name, - "message_id": af_msg.message_id, - } - - # Add usage data if available - if usage_data: - ui_message["usage"] = usage_data - - ui_messages.append(ui_message) - - logger.info(f"Restored {len(ui_messages)} display messages for thread {thread_id}") - return ui_messages - - except Exception as e: - logger.error(f"Error getting thread messages: {e}") - import traceback - - logger.error(traceback.format_exc()) - return [] - - def _extract_display_contents(self, contents: list[Any]) -> list[dict[str, Any]]: - """Extract all user-facing content (text, images, files, etc.) from message contents. - - Filters out internal mechanics like function calls/results while preserving - all content types that should be displayed in the UI. - """ - display_contents = [] - - for content in contents: - content_type = getattr(content, "type", None) - - # Text content - if content_type == "text": - text = getattr(content, "text", "") - - # Handle double-encoded JSON from user messages - if text.startswith('{"role":'): - try: - import json - - parsed = json.loads(text) - if parsed.get("contents"): - for sub_content in parsed["contents"]: - if sub_content.get("type") == "text": - display_contents.append({"type": "text", "text": sub_content.get("text", "")}) - except Exception: - display_contents.append({"type": "text", "text": text}) - else: - display_contents.append({"type": "text", "text": text}) - - # Data content (images, files, PDFs, etc.) - elif content_type == "data": - display_contents.append({ - "type": "data", - "uri": getattr(content, "uri", ""), - "media_type": getattr(content, "media_type", None), - }) - - # URI content (external links to images/files) - elif content_type == "uri": - display_contents.append({ - "type": "uri", - "uri": getattr(content, "uri", ""), - "media_type": getattr(content, "media_type", None), - }) - - # Skip function_call, function_result, and other internal content types - - return display_contents - - async def serialize_thread(self, thread_id: str) -> dict[str, Any] | None: - """Serialize thread state for persistence.""" - thread = self.get_thread(thread_id) - if not thread: - return None - - try: - # Use AgentThread's built-in serialization - serialized_state = await thread.serialize() - - # Add our metadata - agent_id = self.get_agent_for_thread(thread_id) - serialized_state["metadata"] = {"agent_id": agent_id, "thread_id": thread_id} - - return serialized_state - - except Exception as e: - logger.error(f"Error serializing thread {thread_id}: {e}") - return None - - async def deserialize_thread(self, thread_id: str, agent_id: str, serialized_state: dict[str, Any]) -> bool: - """Deserialize thread state from persistence.""" - try: - thread = await AgentThread.deserialize(serialized_state) - # Store the restored thread - self.thread_storage[thread_id] = thread - if agent_id not in self.agent_threads: - self.agent_threads[agent_id] = [] - self.agent_threads[agent_id].append(thread_id) - - return True - - except Exception as e: - logger.error(f"Error deserializing thread {thread_id}: {e}") - return False - async def discover_entities(self) -> list[EntityInfo]: """Discover all available entities. @@ -357,9 +169,11 @@ async def execute_entity(self, entity_id: str, request: AgentFrameworkRequest) - Raw Agent Framework events and trace events """ try: - # Get entity info and object + # Get entity info entity_info = self.get_entity_info(entity_id) - entity_obj = self.entity_discovery.get_entity_object(entity_id) + + # Trigger lazy loading (will return from cache if already loaded) + entity_obj = await self.entity_discovery.load_entity(entity_id) if not entity_obj: raise EntityNotFoundError(f"Entity object for '{entity_id}' not found") @@ -390,7 +204,7 @@ async def execute_entity(self, entity_id: str, request: AgentFrameworkRequest) - yield {"type": "error", "message": str(e), "entity_id": entity_id} async def _execute_agent( - self, agent: Any, request: AgentFrameworkRequest, trace_collector: Any + self, agent: AgentProtocol, request: AgentFrameworkRequest, trace_collector: Any ) -> AsyncGenerator[Any, None]: """Execute Agent Framework agent with trace collection and optional thread support. @@ -406,34 +220,51 @@ async def _execute_agent( # Convert input to proper ChatMessage or string user_message = self._convert_input_to_chat_message(request.input) - # Get thread if provided in extra_body + # Get thread from conversation parameter (OpenAI standard!) thread = None - if request.extra_body and hasattr(request.extra_body, "thread_id") and request.extra_body.thread_id: - thread_id = request.extra_body.thread_id - thread = self.get_thread(thread_id) + conversation_id = request.get_conversation_id() + if conversation_id: + thread = self.conversation_store.get_thread(conversation_id) if thread: - logger.debug(f"Using existing thread: {thread_id}") + logger.debug(f"Using existing conversation: {conversation_id}") else: - logger.warning(f"Thread {thread_id} not found, proceeding without thread") + logger.warning(f"Conversation {conversation_id} not found, proceeding without thread") if isinstance(user_message, str): logger.debug(f"Executing agent with text input: {user_message[:100]}...") else: logger.debug(f"Executing agent with multimodal ChatMessage: {type(user_message)}") + # Check if agent supports streaming + if hasattr(agent, "run_stream") and callable(agent.run_stream): + # Use Agent Framework's native streaming with optional thread + if thread: + async for update in agent.run_stream(user_message, thread=thread): + for trace_event in trace_collector.get_pending_events(): + yield trace_event - # Use Agent Framework's native streaming with optional thread - if thread: - async for update in agent.run_stream(user_message, thread=thread): - for trace_event in trace_collector.get_pending_events(): - yield trace_event + yield update + else: + async for update in agent.run_stream(user_message): + for trace_event in trace_collector.get_pending_events(): + yield trace_event + + yield update + elif hasattr(agent, "run") and callable(agent.run): + # Non-streaming agent - use run() and yield complete response + logger.info("Agent lacks run_stream(), using run() method (non-streaming)") + if thread: + response = await agent.run(user_message, thread=thread) + else: + response = await agent.run(user_message) - yield update - else: - async for update in agent.run_stream(user_message): - for trace_event in trace_collector.get_pending_events(): - yield trace_event + # Yield trace events before response + for trace_event in trace_collector.get_pending_events(): + yield trace_event - yield update + # Yield the complete response (mapper will convert to streaming events) + yield response + else: + raise ValueError("Agent must implement either run() or run_stream() method") except Exception as e: logger.error(f"Error in agent execution: {e}") @@ -455,8 +286,8 @@ async def _execute_workflow( try: # Get input data - prefer structured data from extra_body input_data: str | list[Any] | dict[str, Any] - if request.extra_body and hasattr(request.extra_body, "input_data") and request.extra_body.input_data: - input_data = request.extra_body.input_data + if request.extra_body and isinstance(request.extra_body, dict) and request.extra_body.get("input_data"): + input_data = request.extra_body.get("input_data") # type: ignore logger.debug(f"Using structured input_data from extra_body: {type(input_data)}") else: input_data = request.input @@ -483,6 +314,9 @@ async def _execute_workflow( def _convert_input_to_chat_message(self, input_data: Any) -> Any: """Convert OpenAI Responses API input to Agent Framework ChatMessage or string. + Handles various input formats including text, images, files, and multimodal content. + Falls back to string extraction for simple cases. + Args: input_data: OpenAI ResponseInputParam (List[ResponseInputItemParam]) @@ -512,6 +346,9 @@ def _convert_openai_input_to_chat_message( ) -> Any: """Convert OpenAI ResponseInputParam to Agent Framework ChatMessage. + Processes text, images, files, and other content types from OpenAI format + to Agent Framework ChatMessage with appropriate content objects. + Args: input_items: List of OpenAI ResponseInputItemParam objects (dicts or objects) ChatMessage: ChatMessage class for creating chat messages @@ -597,6 +434,40 @@ def _convert_openai_input_to_chat_message( elif file_url: contents.append(DataContent(uri=file_url, media_type=media_type)) + elif content_type == "function_approval_response": + # Handle function approval response (DevUI extension) + try: + from agent_framework import FunctionApprovalResponseContent, FunctionCallContent + + request_id = content_item.get("request_id", "") + approved = content_item.get("approved", False) + function_call_data = content_item.get("function_call", {}) + + # Create FunctionCallContent from the function_call data + function_call = FunctionCallContent( + call_id=function_call_data.get("id", ""), + name=function_call_data.get("name", ""), + arguments=function_call_data.get("arguments", {}), + ) + + # Create FunctionApprovalResponseContent with correct signature + approval_response = FunctionApprovalResponseContent( + approved, # positional argument + id=request_id, # keyword argument 'id', NOT 'request_id' + function_call=function_call, # FunctionCallContent object + ) + contents.append(approval_response) + logger.info( + f"Added FunctionApprovalResponseContent: id={request_id}, " + f"approved={approved}, call_id={function_call.call_id}" + ) + except ImportError: + logger.warning( + "FunctionApprovalResponseContent not available in agent_framework" + ) + except Exception as e: + logger.error(f"Failed to create FunctionApprovalResponseContent: {e}") + # Handle other OpenAI input item types as needed # (tool calls, function results, etc.) @@ -687,23 +558,6 @@ def _get_start_executor_message_types(self, workflow: Any) -> tuple[Any | None, return start_executor, message_types - def _select_primary_input_type(self, message_types: list[Any]) -> Any | None: - """Choose the most user-friendly input type for workflow kick-off.""" - if not message_types: - return None - - preferred = (str, dict) - - for candidate in preferred: - for message_type in message_types: - if message_type is candidate: - return candidate - origin = get_origin(message_type) - if origin is candidate: - return candidate - - return message_types[0] - def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, Any]) -> Any: """Parse structured input data for workflow execution. @@ -728,7 +582,9 @@ def _parse_structured_workflow_input(self, workflow: Any, input_data: dict[str, return input_data # Get the first (primary) input type - input_type = self._select_primary_input_type(message_types) + from ._utils import select_primary_input_type + + input_type = select_primary_input_type(message_types) if input_type is None: logger.debug("Could not select primary input type for workflow - using raw dict") return input_data @@ -764,7 +620,9 @@ def _parse_raw_workflow_input(self, workflow: Any, raw_input: str) -> Any: return raw_input # Get the first (primary) input type - input_type = self._select_primary_input_type(message_types) + from ._utils import select_primary_input_type + + input_type = select_primary_input_type(message_types) if input_type is None: logger.debug("Could not select primary input type for workflow - using raw string") return raw_input diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index 4866e68230e..488b1be10b1 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -5,6 +5,7 @@ import json import logging import uuid +from collections import OrderedDict from collections.abc import Sequence from datetime import datetime from typing import Any, Union @@ -17,6 +18,8 @@ ResponseErrorEvent, ResponseFunctionCallArgumentsDeltaEvent, ResponseFunctionResultComplete, + ResponseFunctionToolCall, + ResponseOutputItemAddedEvent, ResponseOutputMessage, ResponseOutputText, ResponseReasoningTextDeltaEvent, @@ -24,7 +27,6 @@ ResponseTextDeltaEvent, ResponseTraceEventComplete, ResponseUsage, - ResponseUsageEventComplete, ResponseWorkflowEventComplete, ) @@ -34,19 +36,26 @@ EventType = Union[ ResponseStreamEvent, ResponseWorkflowEventComplete, - ResponseFunctionResultComplete, + ResponseOutputItemAddedEvent, ResponseTraceEventComplete, - ResponseUsageEventComplete, ] class MessageMapper: """Maps Agent Framework messages/responses to OpenAI format.""" - def __init__(self) -> None: - """Initialize Agent Framework message mapper.""" + def __init__(self, max_contexts: int = 1000) -> None: + """Initialize Agent Framework message mapper. + + Args: + max_contexts: Maximum number of contexts to keep in memory (default: 1000) + """ self.sequence_counter = 0 - self._conversion_contexts: dict[int, dict[str, Any]] = {} + self._conversion_contexts: OrderedDict[int, dict[str, Any]] = OrderedDict() + self._max_contexts = max_contexts + + # Track usage per request for final Response.usage (OpenAI standard) + self._usage_accumulator: dict[str, dict[str, int]] = {} # Register content type mappers for all 12 Agent Framework content types self.content_mappers = { @@ -95,7 +104,7 @@ async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> # Import Agent Framework types for proper isinstance checks try: - from agent_framework import AgentRunResponseUpdate, WorkflowEvent + from agent_framework import AgentRunResponse, AgentRunResponseUpdate, WorkflowEvent from agent_framework._workflows._events import AgentRunUpdateEvent # Handle AgentRunUpdateEvent - workflow event wrapping AgentRunResponseUpdate @@ -107,6 +116,10 @@ async def convert_event(self, raw_event: Any, request: AgentFrameworkRequest) -> # If no data, treat as generic workflow event return await self._convert_workflow_event(raw_event, context) + # Handle complete agent response (AgentRunResponse) - for non-streaming agent execution + if isinstance(raw_event, AgentRunResponse): + return await self._convert_agent_response(raw_event, context) + # Handle agent updates (AgentRunResponseUpdate) - for direct agent execution if isinstance(raw_event, AgentRunResponseUpdate): return await self._convert_agent_update(raw_event, context) @@ -159,17 +172,31 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame status="completed", ) - # Create usage object - input_token_count = len(str(request.input)) // 4 if request.input else 0 - output_token_count = len(full_content) // 4 - - usage = ResponseUsage( - input_tokens=input_token_count, - output_tokens=output_token_count, - total_tokens=input_token_count + output_token_count, - input_tokens_details=InputTokensDetails(cached_tokens=0), - output_tokens_details=OutputTokensDetails(reasoning_tokens=0), - ) + # Get usage from accumulator (OpenAI standard) + request_id = str(id(request)) + usage_data = self._usage_accumulator.get(request_id) + + if usage_data: + usage = ResponseUsage( + input_tokens=usage_data["input_tokens"], + output_tokens=usage_data["output_tokens"], + total_tokens=usage_data["total_tokens"], + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) + # Cleanup accumulator + del self._usage_accumulator[request_id] + else: + # Fallback: estimate if no usage was tracked + input_token_count = len(str(request.input)) // 4 if request.input else 0 + output_token_count = len(full_content) // 4 + usage = ResponseUsage( + input_tokens=input_token_count, + output_tokens=output_token_count, + total_tokens=input_token_count + output_token_count, + input_tokens_details=InputTokensDetails(cached_tokens=0), + output_tokens_details=OutputTokensDetails(reasoning_tokens=0), + ) return OpenAIResponse( id=f"resp_{uuid.uuid4().hex[:12]}", @@ -186,10 +213,18 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame except Exception as e: logger.exception(f"Error aggregating response: {e}") return await self._create_error_response(str(e), request) + finally: + # Cleanup: Remove context after aggregation to prevent memory leak + # This handles the common case where streaming completes successfully + request_key = id(request) + if self._conversion_contexts.pop(request_key, None): + logger.debug(f"Cleaned up context for request {request_key} after aggregation") def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, Any]: """Get or create conversion context for this request. + Uses LRU eviction when max_contexts is reached to prevent unbounded memory growth. + Args: request: Request to get context for @@ -197,13 +232,26 @@ def _get_or_create_context(self, request: AgentFrameworkRequest) -> dict[str, An Conversion context dictionary """ request_key = id(request) + if request_key not in self._conversion_contexts: + # Evict oldest context if at capacity (LRU eviction) + if len(self._conversion_contexts) >= self._max_contexts: + evicted_key, _ = self._conversion_contexts.popitem(last=False) + logger.debug(f"Evicted oldest context (key={evicted_key}) - at max capacity ({self._max_contexts})") + self._conversion_contexts[request_key] = { "sequence_counter": 0, "item_id": f"msg_{uuid.uuid4().hex[:8]}", "content_index": 0, "output_index": 0, + "request_id": str(request_key), # For usage accumulation + # Track active function calls: {call_id: {name, item_id, args_chunks}} + "active_function_calls": {}, } + else: + # Move to end (mark as recently used for LRU) + self._conversion_contexts.move_to_end(request_key) + return self._conversion_contexts[request_key] def _next_sequence(self, context: dict[str, Any]) -> int: @@ -240,10 +288,11 @@ async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> S if content_type in self.content_mappers: mapped_events = await self.content_mappers[content_type](content, context) - if isinstance(mapped_events, list): - events.extend(mapped_events) - else: - events.append(mapped_events) + if mapped_events is not None: # Handle None returns (e.g., UsageContent) + if isinstance(mapped_events, list): + events.extend(mapped_events) + else: + events.append(mapped_events) else: # Graceful fallback for unknown content types events.append(await self._create_unknown_content_event(content, context)) @@ -256,6 +305,59 @@ async def _convert_agent_update(self, update: Any, context: dict[str, Any]) -> S return events + async def _convert_agent_response(self, response: Any, context: dict[str, Any]) -> Sequence[Any]: + """Convert complete AgentRunResponse to OpenAI events. + + This handles non-streaming agent execution where agent.run() returns + a complete AgentRunResponse instead of streaming AgentRunResponseUpdate objects. + + Args: + response: Agent run response (AgentRunResponse) + context: Conversion context + + Returns: + List of OpenAI response stream events + """ + events: list[Any] = [] + + try: + # Extract all messages from the response + messages = getattr(response, "messages", []) + + # Convert each message's contents to streaming events + for message in messages: + if hasattr(message, "contents") and message.contents: + for content in message.contents: + content_type = content.__class__.__name__ + + if content_type in self.content_mappers: + mapped_events = await self.content_mappers[content_type](content, context) + if mapped_events is not None: # Handle None returns (e.g., UsageContent) + if isinstance(mapped_events, list): + events.extend(mapped_events) + else: + events.append(mapped_events) + else: + # Graceful fallback for unknown content types + events.append(await self._create_unknown_content_event(content, context)) + + context["content_index"] += 1 + + # Add usage information if present + usage_details = getattr(response, "usage_details", None) + if usage_details: + from agent_framework import UsageContent + + usage_content = UsageContent(details=usage_details) + await self._map_usage_content(usage_content, context) + # Note: _map_usage_content returns None - it accumulates usage for final Response.usage + + except Exception as e: + logger.warning(f"Error converting agent response: {e}") + events.append(await self._create_error_event(str(e), context)) + + return events + async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> Sequence[Any]: """Convert workflow event to structured OpenAI events. @@ -317,42 +419,141 @@ async def _map_reasoning_content(self, content: Any, context: dict[str, Any]) -> async def _map_function_call_content( self, content: Any, context: dict[str, Any] - ) -> list[ResponseFunctionCallArgumentsDeltaEvent]: - """Map FunctionCallContent to ResponseFunctionCallArgumentsDeltaEvent(s).""" - events = [] + ) -> list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent]: + """Map FunctionCallContent to OpenAI events following Responses API spec. - # For streaming, need to chunk the arguments JSON - args_str = json.dumps(content.arguments) if hasattr(content, "arguments") and content.arguments else "{}" + Agent Framework emits FunctionCallContent in two patterns: + 1. First event: call_id + name + empty/no arguments + 2. Subsequent events: empty call_id/name + argument chunks - # Chunk the JSON string for streaming - for chunk in self._chunk_json_string(args_str): + We emit: + 1. response.output_item.added (with full metadata) for the first event + 2. response.function_call_arguments.delta (referencing item_id) for chunks + """ + events: list[ResponseFunctionCallArgumentsDeltaEvent | ResponseOutputItemAddedEvent] = [] + + # CASE 1: New function call (has call_id and name) + # This is the first event that establishes the function call + if content.call_id and content.name: + # Use call_id as item_id (simpler, and call_id uniquely identifies the call) + item_id = content.call_id + + # Track this function call for later argument deltas + context["active_function_calls"][content.call_id] = { + "item_id": item_id, + "name": content.name, + "arguments_chunks": [], + } + + logger.debug(f"New function call: {content.name} (call_id={content.call_id})") + + # Emit response.output_item.added event per OpenAI spec events.append( - ResponseFunctionCallArgumentsDeltaEvent( - type="response.function_call_arguments.delta", - delta=chunk, - item_id=context["item_id"], + ResponseOutputItemAddedEvent( + type="response.output_item.added", + item=ResponseFunctionToolCall( + id=content.call_id, # Use call_id as the item id + call_id=content.call_id, + name=content.name, + arguments="", # Empty initially, will be filled by deltas + type="function_call", + status="in_progress", + ), output_index=context["output_index"], sequence_number=self._next_sequence(context), ) ) + # CASE 2: Argument deltas (content has arguments, possibly without call_id/name) + if content.arguments: + # Find the active function call for these arguments + active_call = self._get_active_function_call(content, context) + + if active_call: + item_id = active_call["item_id"] + + # Convert arguments to string if it's a dict (Agent Framework may send either) + delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments) + + # Emit argument delta referencing the item_id + events.append( + ResponseFunctionCallArgumentsDeltaEvent( + type="response.function_call_arguments.delta", + delta=delta_str, + item_id=item_id, + output_index=context["output_index"], + sequence_number=self._next_sequence(context), + ) + ) + + # Track chunk for debugging + active_call["arguments_chunks"].append(delta_str) + else: + logger.warning(f"Received function call arguments without active call: {content.arguments[:50]}...") + return events + def _get_active_function_call(self, content: Any, context: dict[str, Any]) -> dict[str, Any] | None: + """Find the active function call for this content. + + Uses call_id if present, otherwise falls back to most recent call. + Necessary because Agent Framework may send argument chunks without call_id. + + Args: + content: FunctionCallContent with possible call_id + context: Conversion context with active_function_calls + + Returns: + Active call dict or None + """ + active_calls: dict[str, dict[str, Any]] = context["active_function_calls"] + + # If content has call_id, use it to find the exact call + if hasattr(content, "call_id") and content.call_id: + result = active_calls.get(content.call_id) + return result if result is not None else None + + # Otherwise, use the most recent call (last one added) + # This handles the case where Agent Framework sends argument chunks + # without call_id in subsequent events + if active_calls: + return list(active_calls.values())[-1] + + return None + async def _map_function_result_content( self, content: Any, context: dict[str, Any] ) -> ResponseFunctionResultComplete: - """Map FunctionResultContent to structured event.""" + """Map FunctionResultContent to DevUI custom event. + + DevUI extension: The OpenAI Responses API doesn't stream function execution results + (in OpenAI's model, the application executes functions, not the API). + """ + # Get call_id from content + call_id = getattr(content, "call_id", None) + if not call_id: + call_id = f"call_{uuid.uuid4().hex[:8]}" + + # Extract result + result = getattr(content, "result", None) + exception = getattr(content, "exception", None) + + # Convert result to string + output = result if isinstance(result, str) else json.dumps(result) if result is not None else "" + + # Determine status based on exception + status = "incomplete" if exception else "completed" + + # Generate item_id + item_id = f"item_{uuid.uuid4().hex[:8]}" + + # Return DevUI custom event return ResponseFunctionResultComplete( type="response.function_result.complete", - data={ - "call_id": getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"), - "result": getattr(content, "result", None), - "status": "completed" if not getattr(content, "exception", None) else "failed", - "exception": str(getattr(content, "exception", None)) if getattr(content, "exception", None) else None, - "timestamp": datetime.now().isoformat(), - }, - call_id=getattr(content, "call_id", f"call_{uuid.uuid4().hex[:8]}"), - item_id=context["item_id"], + call_id=call_id, + output=output, + status=status, + item_id=item_id, output_index=context["output_index"], sequence_number=self._next_sequence(context), ) @@ -367,37 +568,34 @@ async def _map_error_content(self, content: Any, context: dict[str, Any]) -> Res sequence_number=self._next_sequence(context), ) - async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> ResponseUsageEventComplete: - """Map UsageContent to structured usage event.""" - # Store usage data in context for aggregation - if "usage_data" not in context: - context["usage_data"] = [] - context["usage_data"].append(content) + async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> None: + """Accumulate usage data for final Response.usage field. + + OpenAI does NOT stream usage events. Usage appears only in final Response. + This method accumulates usage data per request for later inclusion in Response.usage. + Returns: + None - no event emitted (usage goes in final Response.usage) + """ # Extract usage from UsageContent.details (UsageDetails object) details = getattr(content, "details", None) - total_tokens = 0 - prompt_tokens = 0 - completion_tokens = 0 + total_tokens = getattr(details, "total_token_count", 0) or 0 + prompt_tokens = getattr(details, "input_token_count", 0) or 0 + completion_tokens = getattr(details, "output_token_count", 0) or 0 - if details: - total_tokens = getattr(details, "total_token_count", 0) or 0 - prompt_tokens = getattr(details, "input_token_count", 0) or 0 - completion_tokens = getattr(details, "output_token_count", 0) or 0 + # Accumulate for final Response.usage + request_id = context.get("request_id", "default") + if request_id not in self._usage_accumulator: + self._usage_accumulator[request_id] = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0} - return ResponseUsageEventComplete( - type="response.usage.complete", - data={ - "usage_data": details.to_dict() if details and hasattr(details, "to_dict") else {}, - "total_tokens": total_tokens, - "completion_tokens": completion_tokens, - "prompt_tokens": prompt_tokens, - "timestamp": datetime.now().isoformat(), - }, - item_id=context["item_id"], - output_index=context["output_index"], - sequence_number=self._next_sequence(context), - ) + self._usage_accumulator[request_id]["input_tokens"] += prompt_tokens + self._usage_accumulator[request_id]["output_tokens"] += completion_tokens + self._usage_accumulator[request_id]["total_tokens"] += total_tokens + + logger.debug(f"Accumulated usage for {request_id}: {self._usage_accumulator[request_id]}") + + # NO EVENT RETURNED - usage goes in final Response only + return async def _map_data_content(self, content: Any, context: dict[str, Any]) -> ResponseTraceEventComplete: """Map DataContent to structured trace event.""" @@ -462,15 +660,24 @@ async def _map_hosted_vector_store_content( async def _map_approval_request_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]: """Map FunctionApprovalRequestContent to custom event.""" + # Parse arguments to ensure they're always a dict, not a JSON string + # This prevents double-escaping when the frontend calls JSON.stringify() + arguments: dict[str, Any] = {} + if hasattr(content, "function_call"): + if hasattr(content.function_call, "parse_arguments"): + # Use parse_arguments() to convert string arguments to dict + arguments = content.function_call.parse_arguments() or {} + else: + # Fallback to direct access if parse_arguments doesn't exist + arguments = getattr(content.function_call, "arguments", {}) + return { "type": "response.function_approval.requested", "request_id": getattr(content, "id", "unknown"), "function_call": { "id": getattr(content.function_call, "call_id", "") if hasattr(content, "function_call") else "", "name": getattr(content.function_call, "name", "") if hasattr(content, "function_call") else "", - "arguments": getattr(content.function_call, "arguments", {}) - if hasattr(content, "function_call") - else {}, + "arguments": arguments, }, "item_id": context["item_id"], "output_index": context["output_index"], @@ -510,19 +717,15 @@ async def _create_error_event(self, message: str, context: dict[str, Any]) -> Re async def _create_unknown_event(self, event_data: Any, context: dict[str, Any]) -> ResponseStreamEvent: """Create event for unknown event types.""" - text = f"Unknown event: {event_data!s}\\n" + text = f"Unknown event: {event_data!s}\n" return self._create_text_delta_event(text, context) async def _create_unknown_content_event(self, content: Any, context: dict[str, Any]) -> ResponseStreamEvent: """Create event for unknown content types.""" content_type = content.__class__.__name__ - text = f"⚠️ Unknown content type: {content_type}\\n" + text = f"⚠️ Unknown content type: {content_type}\n" return self._create_text_delta_event(text, context) - def _chunk_json_string(self, json_str: str, chunk_size: int = 50) -> list[str]: - """Chunk JSON string for streaming.""" - return [json_str[i : i + chunk_size] for i in range(0, len(json_str), chunk_size)] - async def _create_error_response(self, error_message: str, request: AgentFrameworkRequest) -> OpenAIResponse: """Create error response.""" error_text = f"Error: {error_message}" diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index daffdc96881..e6fd871ca24 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -7,7 +7,7 @@ import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import Any, get_origin +from typing import Any from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware @@ -23,47 +23,6 @@ logger = logging.getLogger(__name__) -def _extract_executor_message_types(executor: Any) -> list[Any]: - """Return declared input types for the given executor.""" - message_types: list[Any] = [] - - try: - input_types = getattr(executor, "input_types", None) - except Exception as exc: # pragma: no cover - defensive logging path - logger.debug(f"Failed to access executor input_types: {exc}") - else: - if input_types: - message_types = list(input_types) - - if not message_types and hasattr(executor, "_handlers"): - try: - handlers = executor._handlers - if isinstance(handlers, dict): - message_types = list(handlers.keys()) - except Exception as exc: # pragma: no cover - defensive logging path - logger.debug(f"Failed to read executor handlers: {exc}") - - return message_types - - -def _select_primary_input_type(message_types: list[Any]) -> Any | None: - """Choose the most user-friendly input type for rendering workflow inputs.""" - if not message_types: - return None - - preferred = (str, dict) - - for candidate in preferred: - for message_type in message_types: - if message_type is candidate: - return candidate - origin = get_origin(message_type) - if origin is candidate: - return candidate - - return message_types[0] - - class DevServer: """Development Server - OpenAI compatible API server for debugging agents.""" @@ -215,7 +174,7 @@ async def discover_entities() -> DiscoveryResponse: @app.get("/v1/entities/{entity_id}/info", response_model=EntityInfo) async def get_entity_info(entity_id: str) -> EntityInfo: - """Get detailed information about a specific entity.""" + """Get detailed information about a specific entity (triggers lazy loading).""" try: executor = await self._ensure_executor() entity_info = executor.get_entity_info(entity_id) @@ -223,86 +182,96 @@ async def get_entity_info(entity_id: str) -> EntityInfo: if not entity_info: raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") + # Trigger lazy loading if entity not yet loaded + # This will import the module and enrich metadata + entity_obj = await executor.entity_discovery.load_entity(entity_id) + + # Get updated entity info (may have been enriched during load) + entity_info = executor.get_entity_info(entity_id) or entity_info + # For workflows, populate additional detailed information - if entity_info.type == "workflow": - entity_obj = executor.entity_discovery.get_entity_object(entity_id) - if entity_obj: - # Get workflow structure - workflow_dump = None - if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)): - try: - workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined] - except Exception: - workflow_dump = None - elif hasattr(entity_obj, "to_json") and callable(getattr(entity_obj, "to_json", None)): - try: - raw_dump = entity_obj.to_json() # type: ignore[attr-defined] - except Exception: - workflow_dump = None - else: - if isinstance(raw_dump, (bytes, bytearray)): - try: - raw_dump = raw_dump.decode() - except Exception: - raw_dump = raw_dump.decode(errors="replace") - if isinstance(raw_dump, str): - try: - parsed_dump = json.loads(raw_dump) - except Exception: - workflow_dump = raw_dump - else: - workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump - else: + if entity_info.type == "workflow" and entity_obj: + # Entity object already loaded by load_entity() above + # Get workflow structure + workflow_dump = None + if hasattr(entity_obj, "to_dict") and callable(getattr(entity_obj, "to_dict", None)): + try: + workflow_dump = entity_obj.to_dict() # type: ignore[attr-defined] + except Exception: + workflow_dump = None + elif hasattr(entity_obj, "to_json") and callable(getattr(entity_obj, "to_json", None)): + try: + raw_dump = entity_obj.to_json() # type: ignore[attr-defined] + except Exception: + workflow_dump = None + else: + if isinstance(raw_dump, (bytes, bytearray)): + try: + raw_dump = raw_dump.decode() + except Exception: + raw_dump = raw_dump.decode(errors="replace") + if isinstance(raw_dump, str): + try: + parsed_dump = json.loads(raw_dump) + except Exception: workflow_dump = raw_dump - elif hasattr(entity_obj, "__dict__"): - workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")} + else: + workflow_dump = parsed_dump if isinstance(parsed_dump, dict) else raw_dump + else: + workflow_dump = raw_dump + elif hasattr(entity_obj, "__dict__"): + workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")} - # Get input schema information - input_schema = {} - input_type_name = "Unknown" - start_executor_id = "" + # Get input schema information + input_schema = {} + input_type_name = "Unknown" + start_executor_id = "" - try: - from ._utils import generate_input_schema + try: + from ._utils import ( + extract_executor_message_types, + generate_input_schema, + select_primary_input_type, + ) - start_executor = entity_obj.get_start_executor() - except Exception as e: - logger.debug(f"Could not extract input info for workflow {entity_id}: {e}") - else: - if start_executor: - start_executor_id = getattr(start_executor, "executor_id", "") or getattr( - start_executor, "id", "" - ) - - message_types = _extract_executor_message_types(start_executor) - input_type = _select_primary_input_type(message_types) - - if input_type: - input_type_name = getattr(input_type, "__name__", str(input_type)) - - # Generate schema using comprehensive schema generation - input_schema = generate_input_schema(input_type) - - if not input_schema: - input_schema = {"type": "string"} - if input_type_name == "Unknown": - input_type_name = "string" - - # Get executor list - executor_list = [] - if hasattr(entity_obj, "executors") and entity_obj.executors: - executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors] - - # Create copy of entity info and populate workflow-specific fields - update_payload: dict[str, Any] = { - "workflow_dump": workflow_dump, - "input_schema": input_schema, - "input_type_name": input_type_name, - "start_executor_id": start_executor_id, - } - if executor_list: - update_payload["executors"] = executor_list - return entity_info.model_copy(update=update_payload) + start_executor = entity_obj.get_start_executor() + except Exception as e: + logger.debug(f"Could not extract input info for workflow {entity_id}: {e}") + else: + if start_executor: + start_executor_id = getattr(start_executor, "executor_id", "") or getattr( + start_executor, "id", "" + ) + + message_types = extract_executor_message_types(start_executor) + input_type = select_primary_input_type(message_types) + + if input_type: + input_type_name = getattr(input_type, "__name__", str(input_type)) + + # Generate schema using comprehensive schema generation + input_schema = generate_input_schema(input_type) + + if not input_schema: + input_schema = {"type": "string"} + if input_type_name == "Unknown": + input_type_name = "string" + + # Get executor list + executor_list = [] + if hasattr(entity_obj, "executors") and entity_obj.executors: + executor_list = [getattr(ex, "executor_id", str(ex)) for ex in entity_obj.executors] + + # Create copy of entity info and populate workflow-specific fields + update_payload: dict[str, Any] = { + "workflow_dump": workflow_dump, + "input_schema": input_schema, + "input_type_name": input_type_name, + "start_executor_id": start_executor_id, + } + if executor_list: + update_payload["executors"] = executor_list + return entity_info.model_copy(update=update_payload) # For non-workflow entities, return as-is return entity_info @@ -313,70 +282,34 @@ async def get_entity_info(entity_id: str) -> EntityInfo: logger.error(f"Error getting entity info for {entity_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to get entity info: {e!s}") from e - @app.post("/v1/entities/add") - async def add_entity(request: dict[str, Any]) -> dict[str, Any]: - """Add entity from URL.""" - try: - url = request.get("url") - metadata = request.get("metadata", {}) - - if not url: - raise HTTPException(status_code=400, detail="URL is required") + @app.post("/v1/entities/{entity_id}/reload") + async def reload_entity(entity_id: str) -> dict[str, Any]: + """Hot reload entity (clears cache, will reimport on next access). - logger.info(f"Attempting to add entity from URL: {url}") - executor = await self._ensure_executor() - entity_info, error_msg = await executor.entity_discovery.fetch_remote_entity(url, metadata) - - if not entity_info: - # Sanitize error message - only return safe, user-friendly errors - logger.error(f"Failed to fetch or validate entity from {url}: {error_msg}") - safe_error = error_msg if error_msg else "Failed to fetch or validate entity" - raise HTTPException(status_code=400, detail=safe_error) - - logger.info(f"Successfully added entity: {entity_info.id}") - return {"success": True, "entity": entity_info.model_dump()} - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error adding entity: {e}", exc_info=True) - # Don't expose internal error details to client - raise HTTPException( - status_code=500, detail="An unexpected error occurred while adding the entity" - ) from e - - @app.delete("/v1/entities/{entity_id}") - async def remove_entity(entity_id: str) -> dict[str, Any]: - """Remove entity by ID.""" + This enables hot reload during development - edit entity code, call this endpoint, + and the next execution will use the updated code without server restart. + """ try: executor = await self._ensure_executor() - # Cleanup entity resources before removal - try: - entity_obj = executor.entity_discovery.get_entity_object(entity_id) - if entity_obj and hasattr(entity_obj, "chat_client"): - client = entity_obj.chat_client - if hasattr(client, "close") and callable(client.close): - if inspect.iscoroutinefunction(client.close): - await client.close() - else: - client.close() - logger.info(f"Closed client for entity: {entity_id}") - except Exception as e: - logger.warning(f"Error closing entity {entity_id} during removal: {e}") + # Check if entity exists + entity_info = executor.get_entity_info(entity_id) + if not entity_info: + raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") - # Remove entity from registry - success = executor.entity_discovery.remove_remote_entity(entity_id) + # Invalidate cache + executor.entity_discovery.invalidate_entity(entity_id) - if success: - return {"success": True} - raise HTTPException(status_code=404, detail="Entity not found or cannot be removed") + return { + "success": True, + "message": f"Entity '{entity_id}' cache cleared. Will reload on next access.", + } except HTTPException: raise except Exception as e: - logger.error(f"Error removing entity {entity_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to remove entity: {e!s}") from e + logger.error(f"Error reloading entity {entity_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to reload entity: {e!s}") from e @app.post("/v1/responses") async def create_response(request: AgentFrameworkRequest, raw_request: Request) -> Any: @@ -421,112 +354,161 @@ async def create_response(request: AgentFrameworkRequest, raw_request: Request) error = OpenAIError.create(f"Execution failed: {e!s}") return JSONResponse(status_code=500, content=error.to_dict()) - @app.post("/v1/threads") - async def create_thread(request_data: dict[str, Any]) -> dict[str, Any]: - """Create a new thread for an agent.""" + # ======================================== + # OpenAI Conversations API (Standard) + # ======================================== + + @app.post("/v1/conversations") + async def create_conversation(request_data: dict[str, Any]) -> dict[str, Any]: + """Create a new conversation - OpenAI standard.""" try: - agent_id = request_data.get("agent_id") - if not agent_id: - raise HTTPException(status_code=400, detail="agent_id is required") + metadata = request_data.get("metadata") + executor = await self._ensure_executor() + conversation = executor.conversation_store.create_conversation(metadata=metadata) + return conversation.model_dump() + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating conversation: {e}") + raise HTTPException(status_code=500, detail=f"Failed to create conversation: {e!s}") from e + @app.get("/v1/conversations") + async def list_conversations(agent_id: str | None = None) -> dict[str, Any]: + """List conversations, optionally filtered by agent_id.""" + try: executor = await self._ensure_executor() - thread_id = executor.create_thread(agent_id) + + if agent_id: + # Filter by agent_id metadata + conversations = executor.conversation_store.list_conversations_by_metadata({"agent_id": agent_id}) + else: + # Return all conversations (for InMemoryStore, list all) + # Note: This assumes list_conversations_by_metadata({}) returns all + conversations = executor.conversation_store.list_conversations_by_metadata({}) return { - "id": thread_id, - "object": "thread", - "created_at": int(__import__("time").time()), - "metadata": {"agent_id": agent_id}, + "object": "list", + "data": [conv.model_dump() for conv in conversations], + "has_more": False, } except HTTPException: raise except Exception as e: - logger.error(f"Error creating thread: {e}") - raise HTTPException(status_code=500, detail=f"Failed to create thread: {e!s}") from e + logger.error(f"Error listing conversations: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list conversations: {e!s}") from e - @app.get("/v1/threads") - async def list_threads(agent_id: str) -> dict[str, Any]: - """List threads for an agent.""" + @app.get("/v1/conversations/{conversation_id}") + async def retrieve_conversation(conversation_id: str) -> dict[str, Any]: + """Get conversation - OpenAI standard.""" try: executor = await self._ensure_executor() - thread_ids = executor.list_threads_for_agent(agent_id) - - # Convert thread IDs to thread objects - threads = [] - for thread_id in thread_ids: - threads.append({"id": thread_id, "object": "thread", "agent_id": agent_id}) - - return {"object": "list", "data": threads} + conversation = executor.conversation_store.get_conversation(conversation_id) + if not conversation: + raise HTTPException(status_code=404, detail="Conversation not found") + return conversation.model_dump() + except HTTPException: + raise except Exception as e: - logger.error(f"Error listing threads: {e}") - raise HTTPException(status_code=500, detail=f"Failed to list threads: {e!s}") from e + logger.error(f"Error getting conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get conversation: {e!s}") from e - @app.get("/v1/threads/{thread_id}") - async def get_thread(thread_id: str) -> dict[str, Any]: - """Get thread information.""" + @app.post("/v1/conversations/{conversation_id}") + async def update_conversation(conversation_id: str, request_data: dict[str, Any]) -> dict[str, Any]: + """Update conversation metadata - OpenAI standard.""" try: executor = await self._ensure_executor() - - # Check if thread exists - thread = executor.get_thread(thread_id) - if not thread: - raise HTTPException(status_code=404, detail="Thread not found") - - # Get the agent that owns this thread - agent_id = executor.get_agent_for_thread(thread_id) - - return {"id": thread_id, "object": "thread", "agent_id": agent_id} + metadata = request_data.get("metadata", {}) + conversation = executor.conversation_store.update_conversation(conversation_id, metadata=metadata) + return conversation.model_dump() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e except HTTPException: raise except Exception as e: - logger.error(f"Error getting thread {thread_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get thread: {e!s}") from e + logger.error(f"Error updating conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update conversation: {e!s}") from e - @app.delete("/v1/threads/{thread_id}") - async def delete_thread(thread_id: str) -> dict[str, Any]: - """Delete a thread.""" + @app.delete("/v1/conversations/{conversation_id}") + async def delete_conversation(conversation_id: str) -> dict[str, Any]: + """Delete conversation - OpenAI standard.""" try: executor = await self._ensure_executor() - success = executor.delete_thread(thread_id) - - if not success: - raise HTTPException(status_code=404, detail="Thread not found") - - return {"id": thread_id, "object": "thread.deleted", "deleted": True} + result = executor.conversation_store.delete_conversation(conversation_id) + return result.model_dump() + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e except HTTPException: raise except Exception as e: - logger.error(f"Error deleting thread {thread_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to delete thread: {e!s}") from e + logger.error(f"Error deleting conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete conversation: {e!s}") from e - @app.get("/v1/threads/{thread_id}/messages") - async def get_thread_messages(thread_id: str) -> dict[str, Any]: - """Get messages from a thread.""" + @app.post("/v1/conversations/{conversation_id}/items") + async def create_conversation_items(conversation_id: str, request_data: dict[str, Any]) -> dict[str, Any]: + """Add items to conversation - OpenAI standard.""" try: executor = await self._ensure_executor() + items = request_data.get("items", []) + conv_items = await executor.conversation_store.add_items(conversation_id, items=items) + return {"object": "list", "data": [item.model_dump() for item in conv_items]} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HTTPException: + raise + except Exception as e: + logger.error(f"Error adding items to conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to add items: {e!s}") from e + + @app.get("/v1/conversations/{conversation_id}/items") + async def list_conversation_items( + conversation_id: str, limit: int = 100, after: str | None = None, order: str = "asc" + ) -> dict[str, Any]: + """List conversation items - OpenAI standard.""" + try: + executor = await self._ensure_executor() + items, has_more = await executor.conversation_store.list_items( + conversation_id, limit=limit, after=after, order=order + ) + return { + "object": "list", + "data": [item.model_dump() for item in items], + "has_more": has_more, + } + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) from e + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing items for conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list items: {e!s}") from e - # Check if thread exists - thread = executor.get_thread(thread_id) - if not thread: - raise HTTPException(status_code=404, detail="Thread not found") - - # Get messages from thread - messages = await executor.get_thread_messages(thread_id) - - return {"object": "list", "data": messages, "thread_id": thread_id} + @app.get("/v1/conversations/{conversation_id}/items/{item_id}") + async def retrieve_conversation_item(conversation_id: str, item_id: str) -> dict[str, Any]: + """Get specific conversation item - OpenAI standard.""" + try: + executor = await self._ensure_executor() + item = executor.conversation_store.get_item(conversation_id, item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + return item.model_dump() except HTTPException: raise except Exception as e: - logger.error(f"Error getting messages for thread {thread_id}: {e}") - raise HTTPException(status_code=500, detail=f"Failed to get thread messages: {e!s}") from e + logger.error(f"Error getting item {item_id} from conversation {conversation_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get item: {e!s}") from e async def _stream_execution( self, executor: AgentFrameworkExecutor, request: AgentFrameworkRequest ) -> AsyncGenerator[str, None]: """Stream execution directly through executor.""" try: - # Direct call to executor - simple and clean + # Collect events for final response.completed event + events = [] + + # Stream all events async for event in executor.execute_streaming(request): + events.append(event) + # IMPORTANT: Check model_dump_json FIRST because to_json() can have newlines (pretty-printing) # which breaks SSE format. model_dump_json() returns single-line JSON. if hasattr(event, "model_dump_json"): @@ -544,6 +526,17 @@ async def _stream_execution( payload = json.dumps(str(event)) yield f"data: {payload}\n\n" + # Aggregate to final response and emit response.completed event (OpenAI standard) + from .models import ResponseCompletedEvent + + final_response = await executor.message_mapper.aggregate_to_response(events, request) + completed_event = ResponseCompletedEvent( + type="response.completed", + response=final_response, + sequence_number=len(events), + ) + yield f"data: {completed_event.model_dump_json()}\n\n" + # Send final done event yield "data: [DONE]\n\n" diff --git a/python/packages/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index a36e5da8dea..58aedbd2f3a 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -10,6 +10,133 @@ logger = logging.getLogger(__name__) +# ============================================================================ +# Agent Metadata Extraction +# ============================================================================ + + +def extract_agent_metadata(entity_object: Any) -> dict[str, Any]: + """Extract agent-specific metadata from an entity object. + + Args: + entity_object: Agent Framework agent object + + Returns: + Dictionary with agent metadata: instructions, model, chat_client_type, + context_providers, and middleware + """ + metadata = { + "instructions": None, + "model": None, + "chat_client_type": None, + "context_providers": None, + "middleware": None, + } + + # Try to get instructions + if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"): + metadata["instructions"] = entity_object.chat_options.instructions + + # Try to get model - check both chat_options and chat_client + if ( + hasattr(entity_object, "chat_options") + and hasattr(entity_object.chat_options, "model_id") + and entity_object.chat_options.model_id + ): + metadata["model"] = entity_object.chat_options.model_id + elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"): + metadata["model"] = entity_object.chat_client.model_id + + # Try to get chat client type + if hasattr(entity_object, "chat_client"): + metadata["chat_client_type"] = entity_object.chat_client.__class__.__name__ + + # Try to get context providers + if ( + hasattr(entity_object, "context_provider") + and entity_object.context_provider + and hasattr(entity_object.context_provider, "__class__") + ): + metadata["context_providers"] = [entity_object.context_provider.__class__.__name__] # type: ignore + + # Try to get middleware + if hasattr(entity_object, "middleware") and entity_object.middleware: + middleware_list: list[str] = [] + for m in entity_object.middleware: + # Try multiple ways to get a good name for middleware + if hasattr(m, "__name__"): # Function or callable + middleware_list.append(m.__name__) + elif hasattr(m, "__class__"): # Class instance + middleware_list.append(m.__class__.__name__) + else: + middleware_list.append(str(m)) + metadata["middleware"] = middleware_list # type: ignore + + return metadata + + +# ============================================================================ +# Workflow Input Type Utilities +# ============================================================================ + + +def extract_executor_message_types(executor: Any) -> list[Any]: + """Extract declared input types for the given executor. + + Args: + executor: Workflow executor object + + Returns: + List of message types that the executor accepts + """ + message_types: list[Any] = [] + + try: + input_types = getattr(executor, "input_types", None) + except Exception as exc: # pragma: no cover - defensive logging path + logger.debug(f"Failed to access executor input_types: {exc}") + else: + if input_types: + message_types = list(input_types) + + if not message_types and hasattr(executor, "_handlers"): + try: + handlers = executor._handlers + if isinstance(handlers, dict): + message_types = list(handlers.keys()) + except Exception as exc: # pragma: no cover - defensive logging path + logger.debug(f"Failed to read executor handlers: {exc}") + + return message_types + + +def select_primary_input_type(message_types: list[Any]) -> Any | None: + """Choose the most user-friendly input type for workflow inputs. + + Prefers str and dict types for better user experience. + + Args: + message_types: List of possible message types + + Returns: + Selected primary input type, or None if list is empty + """ + if not message_types: + return None + + preferred = (str, dict) + + for candidate in preferred: + for message_type in message_types: + if message_type is candidate: + return candidate + origin = get_origin(message_type) + if origin is candidate: + return candidate + + return message_types[0] + + # ============================================================================ # Type System Utilities # ============================================================================ diff --git a/python/packages/devui/agent_framework_devui/models/__init__.py b/python/packages/devui/agent_framework_devui/models/__init__.py index d4c2d0da24e..3db699beffb 100644 --- a/python/packages/devui/agent_framework_devui/models/__init__.py +++ b/python/packages/devui/agent_framework_devui/models/__init__.py @@ -4,11 +4,18 @@ # Import discovery models # Import all OpenAI types directly from the openai package +from openai.types.conversations import Conversation, ConversationDeletedResource +from openai.types.conversations.conversation_item import ConversationItem from openai.types.responses import ( Response, + ResponseCompletedEvent, ResponseErrorEvent, ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallOutputItem, ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, ResponseOutputMessage, ResponseOutputText, ResponseReasoningTextDeltaEvent, @@ -25,14 +32,9 @@ AgentFrameworkRequest, OpenAIError, ResponseFunctionResultComplete, - ResponseFunctionResultDelta, ResponseTraceEvent, ResponseTraceEventComplete, - ResponseTraceEventDelta, - ResponseUsageEventComplete, - ResponseUsageEventDelta, ResponseWorkflowEventComplete, - ResponseWorkflowEventDelta, ) # Type alias for compatibility @@ -41,6 +43,9 @@ # Export all types for easy importing __all__ = [ "AgentFrameworkRequest", + "Conversation", + "ConversationDeletedResource", + "ConversationItem", "DiscoveryResponse", "EntityInfo", "InputTokensDetails", @@ -49,11 +54,15 @@ "OpenAIResponse", "OutputTokensDetails", "Response", + "ResponseCompletedEvent", "ResponseErrorEvent", "ResponseFunctionCallArgumentsDeltaEvent", "ResponseFunctionResultComplete", - "ResponseFunctionResultDelta", + "ResponseFunctionToolCall", + "ResponseFunctionToolCallOutputItem", "ResponseInputParam", + "ResponseOutputItemAddedEvent", + "ResponseOutputItemDoneEvent", "ResponseOutputMessage", "ResponseOutputText", "ResponseReasoningTextDeltaEvent", @@ -61,12 +70,8 @@ "ResponseTextDeltaEvent", "ResponseTraceEvent", "ResponseTraceEventComplete", - "ResponseTraceEventDelta", "ResponseUsage", - "ResponseUsageEventComplete", - "ResponseUsageEventDelta", "ResponseWorkflowEventComplete", - "ResponseWorkflowEventDelta", "ResponsesModel", "ToolParam", ] diff --git a/python/packages/devui/agent_framework_devui/models/_discovery_models.py b/python/packages/devui/agent_framework_devui/models/_discovery_models.py index f4faaf60652..690efa7f9f1 100644 --- a/python/packages/devui/agent_framework_devui/models/_discovery_models.py +++ b/python/packages/devui/agent_framework_devui/models/_discovery_models.py @@ -31,8 +31,7 @@ class EntityInfo(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) # Source information - source: str = "directory" # "directory", "in_memory", "remote_gallery" - original_url: str | None = None + source: str = "directory" # "directory" or "in_memory" # Environment variable requirements required_env_vars: list[EnvVarRequirement] | None = None diff --git a/python/packages/devui/agent_framework_devui/models/_openai_custom.py b/python/packages/devui/agent_framework_devui/models/_openai_custom.py index 91aae0eb5fc..f07f9c7b9c9 100644 --- a/python/packages/devui/agent_framework_devui/models/_openai_custom.py +++ b/python/packages/devui/agent_framework_devui/models/_openai_custom.py @@ -3,7 +3,7 @@ """Custom OpenAI-compatible event types for Agent Framework extensions. These are custom event types that extend beyond the standard OpenAI Responses API -to support Agent Framework specific features like workflows, traces, and function results. +to support Agent Framework specific features like workflows and traces. """ from __future__ import annotations @@ -15,18 +15,6 @@ # Custom Agent Framework OpenAI event types for structured data -class ResponseWorkflowEventDelta(BaseModel): - """Structured workflow event with completion tracking.""" - - type: Literal["response.workflow_event.delta"] = "response.workflow_event.delta" - delta: dict[str, Any] - executor_id: str | None = None - is_complete: bool = False # Track if this is the final part - item_id: str - output_index: int = 0 - sequence_number: int - - class ResponseWorkflowEventComplete(BaseModel): """Complete workflow event data.""" @@ -38,41 +26,6 @@ class ResponseWorkflowEventComplete(BaseModel): sequence_number: int -class ResponseFunctionResultDelta(BaseModel): - """Structured function result with completion tracking.""" - - type: Literal["response.function_result.delta"] = "response.function_result.delta" - delta: dict[str, Any] - call_id: str - is_complete: bool = False - item_id: str - output_index: int = 0 - sequence_number: int - - -class ResponseFunctionResultComplete(BaseModel): - """Complete function result data.""" - - type: Literal["response.function_result.complete"] = "response.function_result.complete" - data: dict[str, Any] # Complete function result data, not delta - call_id: str - item_id: str - output_index: int = 0 - sequence_number: int - - -class ResponseTraceEventDelta(BaseModel): - """Structured trace event with completion tracking.""" - - type: Literal["response.trace.delta"] = "response.trace.delta" - delta: dict[str, Any] - span_id: str | None = None - is_complete: bool = False - item_id: str - output_index: int = 0 - sequence_number: int - - class ResponseTraceEventComplete(BaseModel): """Complete trace event data.""" @@ -84,22 +37,23 @@ class ResponseTraceEventComplete(BaseModel): sequence_number: int -class ResponseUsageEventDelta(BaseModel): - """Structured usage event with completion tracking.""" - - type: Literal["response.usage.delta"] = "response.usage.delta" - delta: dict[str, Any] - is_complete: bool = False - item_id: str - output_index: int = 0 - sequence_number: int +class ResponseFunctionResultComplete(BaseModel): + """DevUI extension: Stream function execution results. + This is a DevUI extension because: + - OpenAI Responses API doesn't stream function results (clients execute functions) + - Agent Framework executes functions server-side, so we stream results for debugging visibility + - ResponseFunctionToolCallOutputItem exists in OpenAI SDK but isn't in ResponseOutputItem union + (it's for Conversations API input, not Responses API streaming output) -class ResponseUsageEventComplete(BaseModel): - """Complete usage event data.""" + This event provides the same structure as OpenAI's function output items but wrapped + in a custom event type since standard events don't support streaming function results. + """ - type: Literal["response.usage.complete"] = "response.usage.complete" - data: dict[str, Any] # Complete usage data, not delta + type: Literal["response.function_result.complete"] = "response.function_result.complete" + call_id: str + output: str + status: Literal["in_progress", "completed", "incomplete"] item_id: str output_index: int = 0 sequence_number: int @@ -110,7 +64,6 @@ class AgentFrameworkExtraBody(BaseModel): """Agent Framework specific routing fields for OpenAI requests.""" entity_id: str - thread_id: str | None = None input_data: dict[str, Any] | None = None model_config = ConfigDict(extra="allow") @@ -118,17 +71,21 @@ class AgentFrameworkExtraBody(BaseModel): # Agent Framework Request Model - Extending real OpenAI types class AgentFrameworkRequest(BaseModel): - """OpenAI ResponseCreateParams with Agent Framework extensions. + """OpenAI ResponseCreateParams with Agent Framework routing. - This properly extends the real OpenAI API request format while adding - our custom routing fields in extra_body. + This properly extends the real OpenAI API request format. + - Uses 'model' field as entity_id (agent/workflow name) + - Uses 'conversation' field for conversation context (OpenAI standard) """ # All OpenAI fields from ResponseCreateParams - model: str + model: str # Used as entity_id in DevUI! input: str | list[Any] # ResponseInputParam stream: bool | None = False + # OpenAI conversation parameter (standard!) + conversation: str | dict[str, Any] | None = None # Union[str, {"id": str}] + # Common OpenAI optional fields instructions: str | None = None metadata: dict[str, Any] | None = None @@ -136,32 +93,35 @@ class AgentFrameworkRequest(BaseModel): max_output_tokens: int | None = None tools: list[dict[str, Any]] | None = None - # Agent Framework extension - strongly typed - extra_body: AgentFrameworkExtraBody | None = None - - entity_id: str | None = None # Allow entity_id as top-level field + # Optional extra_body for advanced use cases + extra_body: dict[str, Any] | None = None model_config = ConfigDict(extra="allow") - def get_entity_id(self) -> str | None: - """Get entity_id from either top-level field or extra_body.""" - # Priority 1: Top-level entity_id field - if self.entity_id: - return self.entity_id - - # Priority 2: entity_id in extra_body - if self.extra_body and hasattr(self.extra_body, "entity_id"): - return self.extra_body.entity_id - + def get_entity_id(self) -> str: + """Get entity_id from model field. + + In DevUI, model IS the entity_id (agent/workflow name). + Simple and clean! + """ + return self.model + + def get_conversation_id(self) -> str | None: + """Extract conversation_id from conversation parameter. + + Supports both string and object forms: + - conversation: "conv_123" + - conversation: {"id": "conv_123"} + """ + if isinstance(self.conversation, str): + return self.conversation + if isinstance(self.conversation, dict): + return self.conversation.get("id") return None def to_openai_params(self) -> dict[str, Any]: """Convert to dict for OpenAI client compatibility.""" - data = self.model_dump(exclude={"extra_body", "entity_id"}, exclude_none=True) - if self.extra_body: - # Don't merge extra_body into main params to keep them separate - data["extra_body"] = self.extra_body - return data + return self.model_dump(exclude_none=True) # Error handling @@ -198,12 +158,7 @@ def to_json(self) -> str: "AgentFrameworkRequest", "OpenAIError", "ResponseFunctionResultComplete", - "ResponseFunctionResultDelta", "ResponseTraceEvent", "ResponseTraceEventComplete", - "ResponseTraceEventDelta", - "ResponseUsageEventComplete", - "ResponseUsageEventDelta", "ResponseWorkflowEventComplete", - "ResponseWorkflowEventDelta", ] diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index-CE4pGoXh.css b/python/packages/devui/agent_framework_devui/ui/assets/index-CE4pGoXh.css new file mode 100644 index 00000000000..c86e173c417 --- /dev/null +++ b/python/packages/devui/agent_framework_devui/ui/assets/index-CE4pGoXh.css @@ -0,0 +1 @@ +/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-emerald-900:oklch(37.8% .077 168.94);--color-emerald-950:oklch(26.2% .051 172.552);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.-top-1{top:calc(var(--spacing)*-1)}.-top-2{top:calc(var(--spacing)*-2)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.-right-1{right:calc(var(--spacing)*-1)}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-3{right:calc(var(--spacing)*3)}.right-4{right:calc(var(--spacing)*4)}.-bottom-2{bottom:calc(var(--spacing)*-2)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-3{bottom:calc(var(--spacing)*3)}.bottom-14{bottom:calc(var(--spacing)*14)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-1\.5{margin-right:calc(var(--spacing)*1.5)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-1\.5{margin-left:calc(var(--spacing)*1.5)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-96{height:calc(var(--spacing)*96)}.h-\[1\.2rem\]{height:1.2rem}.h-\[500px\]{height:500px}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-20{max-height:calc(var(--spacing)*20)}.max-h-24{max-height:calc(var(--spacing)*24)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[240px\]{min-height:240px}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-\[1\.2rem\]{width:1.2rem}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.cursor-row-resize{cursor:row-resize}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-t{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-\[\#643FB2\]\/40{border-color:#643fb266}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500\/30{border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/30{border-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.border-blue-500\/40{border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.border-blue-500\/40{border-color:color-mix(in oklab,var(--color-blue-500)40%,transparent)}}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-destructive\/50{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/50{border-color:color-mix(in oklab,var(--destructive)50%,transparent)}}.border-destructive\/70{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/70{border-color:color-mix(in oklab,var(--destructive)70%,transparent)}}.border-emerald-300{border-color:var(--color-emerald-300)}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/30{border-color:#00c7584d}@supports (color:color-mix(in lab,red,red)){.border-green-500\/30{border-color:color-mix(in oklab,var(--color-green-500)30%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-input{border-color:var(--input)}.border-muted{border-color:var(--muted)}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-500{border-color:var(--color-orange-500)}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/5{background-color:#643fb20d}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/5{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/5{background-color:color-mix(in oklab,var(--destructive)5%,transparent)}}.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-destructive\/80{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/80{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted,.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-500{background-color:var(--color-orange-500)}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pb-12{padding-bottom:calc(var(--spacing)*12)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive{color:var(--destructive)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-muted-foreground,.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-800{color:var(--color-orange-800)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:bg-\[\#643FB2\]\/10:hover{background-color:#643fb21a}.hover\:bg-accent:hover{background-color:var(--accent)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-500\/10:hover{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-blue-500\/10:hover{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.hover\:bg-destructive\/20:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/20:hover{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-green-500\/10:hover{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.hover\:bg-green-500\/10:hover{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/30:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:col-span-2{grid-column:span 2/span 2}.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:gap-8{gap:calc(var(--spacing)*8)}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-\[\#8B5CF6\]\/40:is(.dark *){border-color:#8b5cf666}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-500\/30:is(.dark *){border-color:#3080ff4d}@supports (color:color-mix(in lab,red,red)){.dark\:border-blue-500\/30:is(.dark *){border-color:color-mix(in oklab,var(--color-blue-500)30%,transparent)}}.dark\:border-blue-500\/40:is(.dark *){border-color:#3080ff66}@supports (color:color-mix(in lab,red,red)){.dark\:border-blue-500\/40:is(.dark *){border-color:color-mix(in oklab,var(--color-blue-500)40%,transparent)}}.dark\:border-blue-600:is(.dark *){border-color:var(--color-blue-600)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-emerald-600:is(.dark *){border-color:var(--color-emerald-600)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-400\/30:is(.dark *){border-color:#05df724d}@supports (color:color-mix(in lab,red,red)){.dark\:border-green-400\/30:is(.dark *){border-color:color-mix(in oklab,var(--color-green-400)30%,transparent)}}.dark\:border-green-400\/40:is(.dark *){border-color:#05df7266}@supports (color:color-mix(in lab,red,red)){.dark\:border-green-400\/40:is(.dark *){border-color:color-mix(in oklab,var(--color-green-400)40%,transparent)}}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/5:is(.dark *){background-color:#8b5cf60d}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-background:is(.dark *){background-color:var(--background)}.dark\:bg-blue-500\/5:is(.dark *){background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/5:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/50:is(.dark *){background-color:#1c398e80}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)50%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/20:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/20:is(.dark *){background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-emerald-900\/50:is(.dark *){background-color:#004e3b80}@supports (color:color-mix(in lab,red,red)){.dark\:bg-emerald-900\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-emerald-900)50%,transparent)}}.dark\:bg-emerald-950\/50:is(.dark *){background-color:#002c2280}@supports (color:color-mix(in lab,red,red)){.dark\:bg-emerald-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-emerald-950)50%,transparent)}}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-400\/5:is(.dark *){background-color:#05df720d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-400\/5:is(.dark *){background-color:color-mix(in oklab,var(--color-green-400)5%,transparent)}}.dark\:bg-green-400\/10:is(.dark *){background-color:#05df721a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-400\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-400)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-emerald-200:is(.dark *){color:var(--color-emerald-200)}.dark\:text-emerald-300:is(.dark *){color:var(--color-emerald-300)}.dark\:text-emerald-400:is(.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:bg-\[\#8B5CF6\]\/10:is(.dark *):hover{background-color:#8b5cf61a}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-blue-500\/10:is(.dark *):hover{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-blue-500\/10:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:hover\:bg-destructive\/30:is(.dark *):hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-destructive\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-green-400\/10:is(.dark *):hover{background-color:#05df721a}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-green-400\/10:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-green-400)10%,transparent)}}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index-D0SfShuZ.js b/python/packages/devui/agent_framework_devui/ui/assets/index-D0SfShuZ.js deleted file mode 100644 index ba82315af16..00000000000 --- a/python/packages/devui/agent_framework_devui/ui/assets/index-D0SfShuZ.js +++ /dev/null @@ -1,445 +0,0 @@ -function v_(e,r){for(var o=0;os[i]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))s(i);new MutationObserver(i=>{for(const u of i)if(u.type==="childList")for(const d of u.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function o(i){const u={};return i.integrity&&(u.integrity=i.integrity),i.referrerPolicy&&(u.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?u.credentials="include":i.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function s(i){if(i.ep)return;i.ep=!0;const u=o(i);fetch(i.href,u)}})();function Rm(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var ph={exports:{}},ui={};/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var cy;function b_(){if(cy)return ui;cy=1;var e=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function o(s,i,u){var d=null;if(u!==void 0&&(d=""+u),i.key!==void 0&&(d=""+i.key),"key"in i){u={};for(var f in i)f!=="key"&&(u[f]=i[f])}else u=i;return i=u.ref,{$$typeof:e,type:s,key:d,ref:i!==void 0?i:null,props:u}}return ui.Fragment=r,ui.jsx=o,ui.jsxs=o,ui}var uy;function w_(){return uy||(uy=1,ph.exports=b_()),ph.exports}var c=w_(),gh={exports:{}},Re={};/** - * @license React - * react.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var dy;function S_(){if(dy)return Re;dy=1;var e=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),o=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),u=Symbol.for("react.consumer"),d=Symbol.for("react.context"),f=Symbol.for("react.forward_ref"),m=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),g=Symbol.for("react.lazy"),v=Symbol.iterator;function y(k){return k===null||typeof k!="object"?null:(k=v&&k[v]||k["@@iterator"],typeof k=="function"?k:null)}var w={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},N=Object.assign,S={};function _(k,B,K){this.props=k,this.context=B,this.refs=S,this.updater=K||w}_.prototype.isReactComponent={},_.prototype.setState=function(k,B){if(typeof k!="object"&&typeof k!="function"&&k!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,k,B,"setState")},_.prototype.forceUpdate=function(k){this.updater.enqueueForceUpdate(this,k,"forceUpdate")};function A(){}A.prototype=_.prototype;function R(k,B,K){this.props=k,this.context=B,this.refs=S,this.updater=K||w}var E=R.prototype=new A;E.constructor=R,N(E,_.prototype),E.isPureReactComponent=!0;var M=Array.isArray,O={H:null,A:null,T:null,S:null,V:null},H=Object.prototype.hasOwnProperty;function I(k,B,K,Z,te,he){return K=he.ref,{$$typeof:e,type:k,key:B,ref:K!==void 0?K:null,props:he}}function U(k,B){return I(k.type,B,void 0,void 0,void 0,k.props)}function Y(k){return typeof k=="object"&&k!==null&&k.$$typeof===e}function Q(k){var B={"=":"=0",":":"=2"};return"$"+k.replace(/[=:]/g,function(K){return B[K]})}var ee=/\/+/g;function q(k,B){return typeof k=="object"&&k!==null&&k.key!=null?Q(""+k.key):B.toString(36)}function X(){}function z(k){switch(k.status){case"fulfilled":return k.value;case"rejected":throw k.reason;default:switch(typeof k.status=="string"?k.then(X,X):(k.status="pending",k.then(function(B){k.status==="pending"&&(k.status="fulfilled",k.value=B)},function(B){k.status==="pending"&&(k.status="rejected",k.reason=B)})),k.status){case"fulfilled":return k.value;case"rejected":throw k.reason}}throw k}function $(k,B,K,Z,te){var he=typeof k;(he==="undefined"||he==="boolean")&&(k=null);var fe=!1;if(k===null)fe=!0;else switch(he){case"bigint":case"string":case"number":fe=!0;break;case"object":switch(k.$$typeof){case e:case r:fe=!0;break;case g:return fe=k._init,$(fe(k._payload),B,K,Z,te)}}if(fe)return te=te(k),fe=Z===""?"."+q(k,0):Z,M(te)?(K="",fe!=null&&(K=fe.replace(ee,"$&/")+"/"),$(te,B,K,"",function(me){return me})):te!=null&&(Y(te)&&(te=U(te,K+(te.key==null||k&&k.key===te.key?"":(""+te.key).replace(ee,"$&/")+"/")+fe)),B.push(te)),1;fe=0;var ne=Z===""?".":Z+":";if(M(k))for(var ae=0;ae>>1,k=C[P];if(0>>1;Pi(Z,L))tei(he,Z)?(C[P]=he,C[te]=L,P=te):(C[P]=Z,C[K]=L,P=K);else if(tei(he,L))C[P]=he,C[te]=L,P=te;else break e}}return D}function i(C,D){var L=C.sortIndex-D.sortIndex;return L!==0?L:C.id-D.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],p=[],g=1,v=null,y=3,w=!1,N=!1,S=!1,_=!1,A=typeof setTimeout=="function"?setTimeout:null,R=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function M(C){for(var D=o(p);D!==null;){if(D.callback===null)s(p);else if(D.startTime<=C)s(p),D.sortIndex=D.expirationTime,r(m,D);else break;D=o(p)}}function O(C){if(S=!1,M(C),!N)if(o(m)!==null)N=!0,H||(H=!0,q());else{var D=o(p);D!==null&&$(O,D.startTime-C)}}var H=!1,I=-1,U=5,Y=-1;function Q(){return _?!0:!(e.unstable_now()-YC&&Q());){var P=v.callback;if(typeof P=="function"){v.callback=null,y=v.priorityLevel;var k=P(v.expirationTime<=C);if(C=e.unstable_now(),typeof k=="function"){v.callback=k,M(C),D=!0;break t}v===o(m)&&s(m),M(C)}else s(m);v=o(m)}if(v!==null)D=!0;else{var B=o(p);B!==null&&$(O,B.startTime-C),D=!1}}break e}finally{v=null,y=L,w=!1}D=void 0}}finally{D?q():H=!1}}}var q;if(typeof E=="function")q=function(){E(ee)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,z=X.port2;X.port1.onmessage=ee,q=function(){z.postMessage(null)}}else q=function(){A(ee,0)};function $(C,D){I=A(function(){C(e.unstable_now())},D)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(C){C.callback=null},e.unstable_forceFrameRate=function(C){0>C||125P?(C.sortIndex=L,r(p,C),o(m)===null&&C===o(p)&&(S?(R(I),I=-1):S=!0,$(O,L-P))):(C.sortIndex=k,r(m,C),N||w||(N=!0,H||(H=!0,q()))),C},e.unstable_shouldYield=Q,e.unstable_wrapCallback=function(C){var D=y;return function(){var L=y;y=D;try{return C.apply(this,arguments)}finally{y=L}}}})(vh)),vh}var my;function E_(){return my||(my=1,yh.exports=N_()),yh.exports}var bh={exports:{}},kt={};/** - * @license React - * react-dom.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var py;function __(){if(py)return kt;py=1;var e=Bi();function r(m){var p="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(r){console.error(r)}}return e(),bh.exports=__(),bh.exports}/** - * @license React - * react-dom-client.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var xy;function j_(){if(xy)return di;xy=1;var e=E_(),r=Bi(),o=fb();function s(t){var n="https://react.dev/errors/"+t;if(1k||(t.current=P[k],P[k]=null,k--)}function Z(t,n){k++,P[k]=t.current,t.current=n}var te=B(null),he=B(null),fe=B(null),ne=B(null);function ae(t,n){switch(Z(fe,n),Z(he,t),Z(te,null),n.nodeType){case 9:case 11:t=(t=n.documentElement)&&(t=t.namespaceURI)?H0(t):0;break;default:if(t=n.tagName,n=n.namespaceURI)n=H0(n),t=I0(n,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}K(te),Z(te,t)}function me(){K(te),K(he),K(fe)}function ge(t){t.memoizedState!==null&&Z(ne,t);var n=te.current,a=I0(n,t.type);n!==a&&(Z(he,t),Z(te,a))}function se(t){he.current===t&&(K(te),K(he)),ne.current===t&&(K(ne),ai._currentValue=L)}var ie=Object.prototype.hasOwnProperty,de=e.unstable_scheduleCallback,pe=e.unstable_cancelCallback,we=e.unstable_shouldYield,Ne=e.unstable_requestPaint,Se=e.unstable_now,Ie=e.unstable_getCurrentPriorityLevel,St=e.unstable_ImmediatePriority,lt=e.unstable_UserBlockingPriority,ke=e.unstable_NormalPriority,Qe=e.unstable_LowPriority,xt=e.unstable_IdlePriority,hn=e.log,tn=e.unstable_setDisableYieldValue,nn=null,ht=null;function mn(t){if(typeof hn=="function"&&tn(t),ht&&typeof ht.setStrictMode=="function")try{ht.setStrictMode(nn,t)}catch{}}var Tt=Math.clz32?Math.clz32:nd,as=Math.log,td=Math.LN2;function nd(t){return t>>>=0,t===0?32:31-(as(t)/td|0)|0}var Yo=256,qo=4194304;function Gn(t){var n=t&42;if(n!==0)return n;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Go(t,n,a){var l=t.pendingLanes;if(l===0)return 0;var h=0,x=t.suspendedLanes,j=t.pingedLanes;t=t.warmLanes;var T=l&134217727;return T!==0?(l=T&~x,l!==0?h=Gn(l):(j&=T,j!==0?h=Gn(j):a||(a=T&~t,a!==0&&(h=Gn(a))))):(T=l&~x,T!==0?h=Gn(T):j!==0?h=Gn(j):a||(a=l&~t,a!==0&&(h=Gn(a)))),h===0?0:n!==0&&n!==h&&(n&x)===0&&(x=h&-h,a=n&-n,x>=a||x===32&&(a&4194048)!==0)?n:h}function io(t,n){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&n)===0}function rd(t,n){switch(t){case 1:case 2:case 4:case 8:case 64:return n+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return n+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function al(){var t=Yo;return Yo<<=1,(Yo&4194048)===0&&(Yo=256),t}function sl(){var t=qo;return qo<<=1,(qo&62914560)===0&&(qo=4194304),t}function ss(t){for(var n=[],a=0;31>a;a++)n.push(t);return n}function lo(t,n){t.pendingLanes|=n,n!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function od(t,n,a,l,h,x){var j=t.pendingLanes;t.pendingLanes=a,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=a,t.entangledLanes&=a,t.errorRecoveryDisabledLanes&=a,t.shellSuspendCounter=0;var T=t.entanglements,V=t.expirationTimes,J=t.hiddenUpdates;for(a=j&~a;0)":-1h||V[l]!==J[h]){var le=` -`+V[l].replace(" at new "," at ");return t.displayName&&le.includes("")&&(le=le.replace("",t.displayName)),le}while(1<=l&&0<=h);break}}}finally{ms=!1,Error.prepareStackTrace=a}return(a=t?t.displayName||t.name:"")?Kn(a):""}function ud(t){switch(t.tag){case 26:case 27:case 5:return Kn(t.type);case 16:return Kn("Lazy");case 13:return Kn("Suspense");case 19:return Kn("SuspenseList");case 0:case 15:return ps(t.type,!1);case 11:return ps(t.type.render,!1);case 1:return ps(t.type,!0);case 31:return Kn("Activity");default:return""}}function ml(t){try{var n="";do n+=ud(t),t=t.return;while(t);return n}catch(a){return` -Error generating stack: `+a.message+` -`+a.stack}}function zt(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function pl(t){var n=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function dd(t){var n=pl(t)?"checked":"value",a=Object.getOwnPropertyDescriptor(t.constructor.prototype,n),l=""+t[n];if(!t.hasOwnProperty(n)&&typeof a<"u"&&typeof a.get=="function"&&typeof a.set=="function"){var h=a.get,x=a.set;return Object.defineProperty(t,n,{configurable:!0,get:function(){return h.call(this)},set:function(j){l=""+j,x.call(this,j)}}),Object.defineProperty(t,n,{enumerable:a.enumerable}),{getValue:function(){return l},setValue:function(j){l=""+j},stopTracking:function(){t._valueTracker=null,delete t[n]}}}}function Zo(t){t._valueTracker||(t._valueTracker=dd(t))}function gs(t){if(!t)return!1;var n=t._valueTracker;if(!n)return!0;var a=n.getValue(),l="";return t&&(l=pl(t)?t.checked?"true":"false":t.value),t=l,t!==a?(n.setValue(t),!0):!1}function Ko(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var fd=/[\n"\\]/g;function Lt(t){return t.replace(fd,function(n){return"\\"+n.charCodeAt(0).toString(16)+" "})}function uo(t,n,a,l,h,x,j,T){t.name="",j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"?t.type=j:t.removeAttribute("type"),n!=null?j==="number"?(n===0&&t.value===""||t.value!=n)&&(t.value=""+zt(n)):t.value!==""+zt(n)&&(t.value=""+zt(n)):j!=="submit"&&j!=="reset"||t.removeAttribute("value"),n!=null?xs(t,j,zt(n)):a!=null?xs(t,j,zt(a)):l!=null&&t.removeAttribute("value"),h==null&&x!=null&&(t.defaultChecked=!!x),h!=null&&(t.checked=h&&typeof h!="function"&&typeof h!="symbol"),T!=null&&typeof T!="function"&&typeof T!="symbol"&&typeof T!="boolean"?t.name=""+zt(T):t.removeAttribute("name")}function gl(t,n,a,l,h,x,j,T){if(x!=null&&typeof x!="function"&&typeof x!="symbol"&&typeof x!="boolean"&&(t.type=x),n!=null||a!=null){if(!(x!=="submit"&&x!=="reset"||n!=null))return;a=a!=null?""+zt(a):"",n=n!=null?""+zt(n):a,T||n===t.value||(t.value=n),t.defaultValue=n}l=l??h,l=typeof l!="function"&&typeof l!="symbol"&&!!l,t.checked=T?t.checked:!!l,t.defaultChecked=!!l,j!=null&&typeof j!="function"&&typeof j!="symbol"&&typeof j!="boolean"&&(t.name=j)}function xs(t,n,a){n==="number"&&Ko(t.ownerDocument)===t||t.defaultValue===""+a||(t.defaultValue=""+a)}function Wn(t,n,a,l){if(t=t.options,n){n={};for(var h=0;h"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),xd=!1;if(Qn)try{var vs={};Object.defineProperty(vs,"passive",{get:function(){xd=!0}}),window.addEventListener("test",vs,vs),window.removeEventListener("test",vs,vs)}catch{xd=!1}var jr=null,yd=null,yl=null;function Pp(){if(yl)return yl;var t,n=yd,a=n.length,l,h="value"in jr?jr.value:jr.textContent,x=h.length;for(t=0;t=Ss),Xp=" ",Fp=!1;function Zp(t,n){switch(t){case"keyup":return VN.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Kp(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var ea=!1;function qN(t,n){switch(t){case"compositionend":return Kp(n);case"keypress":return n.which!==32?null:(Fp=!0,Xp);case"textInput":return t=n.data,t===Xp&&Fp?null:t;default:return null}}function GN(t,n){if(ea)return t==="compositionend"||!Nd&&Zp(t,n)?(t=Pp(),yl=yd=jr=null,ea=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:a,offset:n-t};t=l}e:{for(;a;){if(a.nextSibling){a=a.nextSibling;break e}a=a.parentNode}a=void 0}a=og(a)}}function sg(t,n){return t&&n?t===n?!0:t&&t.nodeType===3?!1:n&&n.nodeType===3?sg(t,n.parentNode):"contains"in t?t.contains(n):t.compareDocumentPosition?!!(t.compareDocumentPosition(n)&16):!1:!1}function ig(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var n=Ko(t.document);n instanceof t.HTMLIFrameElement;){try{var a=typeof n.contentWindow.location.href=="string"}catch{a=!1}if(a)t=n.contentWindow;else break;n=Ko(t.document)}return n}function jd(t){var n=t&&t.nodeName&&t.nodeName.toLowerCase();return n&&(n==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||n==="textarea"||t.contentEditable==="true")}var eE=Qn&&"documentMode"in document&&11>=document.documentMode,ta=null,Cd=null,js=null,Ad=!1;function lg(t,n,a){var l=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;Ad||ta==null||ta!==Ko(l)||(l=ta,"selectionStart"in l&&jd(l)?l={start:l.selectionStart,end:l.selectionEnd}:(l=(l.ownerDocument&&l.ownerDocument.defaultView||window).getSelection(),l={anchorNode:l.anchorNode,anchorOffset:l.anchorOffset,focusNode:l.focusNode,focusOffset:l.focusOffset}),js&&_s(js,l)||(js=l,l=lc(Cd,"onSelect"),0>=j,h-=j,er=1<<32-Tt(n)+h|a<x?x:8;var j=C.T,T={};C.T=T,mf(t,!1,n,a);try{var V=h(),J=C.S;if(J!==null&&J(T,V),V!==null&&typeof V=="object"&&typeof V.then=="function"){var le=cE(V,l);Ps(t,n,le,Zt(t))}else Ps(t,n,l,Zt(t))}catch(ue){Ps(t,n,{then:function(){},status:"rejected",reason:ue},Zt())}finally{D.p=x,C.T=j}}function mE(){}function ff(t,n,a,l){if(t.tag!==5)throw Error(s(476));var h=cx(t).queue;lx(t,h,n,L,a===null?mE:function(){return ux(t),a(l)})}function cx(t){var n=t.memoizedState;if(n!==null)return n;n={memoizedState:L,baseState:L,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:or,lastRenderedState:L},next:null};var a={};return n.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:or,lastRenderedState:a},next:null},t.memoizedState=n,t=t.alternate,t!==null&&(t.memoizedState=n),n}function ux(t){var n=cx(t).next.queue;Ps(t,n,{},Zt())}function hf(){return Mt(ai)}function dx(){return dt().memoizedState}function fx(){return dt().memoizedState}function pE(t){for(var n=t.return;n!==null;){switch(n.tag){case 24:case 3:var a=Zt();t=Mr(a);var l=kr(n,t,a);l!==null&&(Kt(l,n,a),zs(l,n,a)),n={cache:$d()},t.payload=n;return}n=n.return}}function gE(t,n,a){var l=Zt();a={lane:l,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null},$l(t)?mx(n,a):(a=Rd(t,n,a,l),a!==null&&(Kt(a,t,l),px(a,n,l)))}function hx(t,n,a){var l=Zt();Ps(t,n,a,l)}function Ps(t,n,a,l){var h={lane:l,revertLane:0,action:a,hasEagerState:!1,eagerState:null,next:null};if($l(t))mx(n,h);else{var x=t.alternate;if(t.lanes===0&&(x===null||x.lanes===0)&&(x=n.lastRenderedReducer,x!==null))try{var j=n.lastRenderedState,T=x(j,a);if(h.hasEagerState=!0,h.eagerState=T,Yt(T,j))return _l(t,n,h,0),Je===null&&El(),!1}catch{}finally{}if(a=Rd(t,n,h,l),a!==null)return Kt(a,t,l),px(a,n,l),!0}return!1}function mf(t,n,a,l){if(l={lane:2,revertLane:Gf(),action:l,hasEagerState:!1,eagerState:null,next:null},$l(t)){if(n)throw Error(s(479))}else n=Rd(t,a,l,2),n!==null&&Kt(n,t,2)}function $l(t){var n=t.alternate;return t===De||n!==null&&n===De}function mx(t,n){da=Ll=!0;var a=t.pending;a===null?n.next=n:(n.next=a.next,a.next=n),t.pending=n}function px(t,n,a){if((a&4194048)!==0){var l=n.lanes;l&=t.pendingLanes,a|=l,n.lanes=a,is(t,a)}}var Vl={readContext:Mt,use:Il,useCallback:st,useContext:st,useEffect:st,useImperativeHandle:st,useLayoutEffect:st,useInsertionEffect:st,useMemo:st,useReducer:st,useRef:st,useState:st,useDebugValue:st,useDeferredValue:st,useTransition:st,useSyncExternalStore:st,useId:st,useHostTransitionStatus:st,useFormState:st,useActionState:st,useOptimistic:st,useMemoCache:st,useCacheRefresh:st},gx={readContext:Mt,use:Il,useCallback:function(t,n){return It().memoizedState=[t,n===void 0?null:n],t},useContext:Mt,useEffect:Jg,useImperativeHandle:function(t,n,a){a=a!=null?a.concat([t]):null,Pl(4194308,4,rx.bind(null,n,t),a)},useLayoutEffect:function(t,n){return Pl(4194308,4,t,n)},useInsertionEffect:function(t,n){Pl(4,2,t,n)},useMemo:function(t,n){var a=It();n=n===void 0?null:n;var l=t();if(No){mn(!0);try{t()}finally{mn(!1)}}return a.memoizedState=[l,n],l},useReducer:function(t,n,a){var l=It();if(a!==void 0){var h=a(n);if(No){mn(!0);try{a(n)}finally{mn(!1)}}}else h=n;return l.memoizedState=l.baseState=h,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:h},l.queue=t,t=t.dispatch=gE.bind(null,De,t),[l.memoizedState,t]},useRef:function(t){var n=It();return t={current:t},n.memoizedState=t},useState:function(t){t=lf(t);var n=t.queue,a=hx.bind(null,De,n);return n.dispatch=a,[t.memoizedState,a]},useDebugValue:uf,useDeferredValue:function(t,n){var a=It();return df(a,t,n)},useTransition:function(){var t=lf(!1);return t=lx.bind(null,De,t.queue,!0,!1),It().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,n,a){var l=De,h=It();if(Ye){if(a===void 0)throw Error(s(407));a=a()}else{if(a=n(),Je===null)throw Error(s(349));(Ue&124)!==0||Hg(l,n,a)}h.memoizedState=a;var x={value:a,getSnapshot:n};return h.queue=x,Jg(Bg.bind(null,l,x,t),[t]),l.flags|=2048,ha(9,Ul(),Ig.bind(null,l,x,a,n),null),a},useId:function(){var t=It(),n=Je.identifierPrefix;if(Ye){var a=tr,l=er;a=(l&~(1<<32-Tt(l)-1)).toString(32)+a,n="«"+n+"R"+a,a=Hl++,0Ae?(wt=Ee,Ee=null):wt=Ee.sibling;var $e=re(F,Ee,W[Ae],ce);if($e===null){Ee===null&&(Ee=wt);break}t&&Ee&&$e.alternate===null&&n(F,Ee),G=x($e,G,Ae),ze===null?xe=$e:ze.sibling=$e,ze=$e,Ee=wt}if(Ae===W.length)return a(F,Ee),Ye&&xo(F,Ae),xe;if(Ee===null){for(;AeAe?(wt=Ee,Ee=null):wt=Ee.sibling;var Xr=re(F,Ee,$e.value,ce);if(Xr===null){Ee===null&&(Ee=wt);break}t&&Ee&&Xr.alternate===null&&n(F,Ee),G=x(Xr,G,Ae),ze===null?xe=Xr:ze.sibling=Xr,ze=Xr,Ee=wt}if($e.done)return a(F,Ee),Ye&&xo(F,Ae),xe;if(Ee===null){for(;!$e.done;Ae++,$e=W.next())$e=ue(F,$e.value,ce),$e!==null&&(G=x($e,G,Ae),ze===null?xe=$e:ze.sibling=$e,ze=$e);return Ye&&xo(F,Ae),xe}for(Ee=l(Ee);!$e.done;Ae++,$e=W.next())$e=oe(Ee,F,Ae,$e.value,ce),$e!==null&&(t&&$e.alternate!==null&&Ee.delete($e.key===null?Ae:$e.key),G=x($e,G,Ae),ze===null?xe=$e:ze.sibling=$e,ze=$e);return t&&Ee.forEach(function(y_){return n(F,y_)}),Ye&&xo(F,Ae),xe}function Ke(F,G,W,ce){if(typeof W=="object"&&W!==null&&W.type===N&&W.key===null&&(W=W.props.children),typeof W=="object"&&W!==null){switch(W.$$typeof){case y:e:{for(var xe=W.key;G!==null;){if(G.key===xe){if(xe=W.type,xe===N){if(G.tag===7){a(F,G.sibling),ce=h(G,W.props.children),ce.return=F,F=ce;break e}}else if(G.elementType===xe||typeof xe=="object"&&xe!==null&&xe.$$typeof===U&&yx(xe)===G.type){a(F,G.sibling),ce=h(G,W.props),Vs(ce,W),ce.return=F,F=ce;break e}a(F,G);break}else n(F,G);G=G.sibling}W.type===N?(ce=po(W.props.children,F.mode,ce,W.key),ce.return=F,F=ce):(ce=Cl(W.type,W.key,W.props,null,F.mode,ce),Vs(ce,W),ce.return=F,F=ce)}return j(F);case w:e:{for(xe=W.key;G!==null;){if(G.key===xe)if(G.tag===4&&G.stateNode.containerInfo===W.containerInfo&&G.stateNode.implementation===W.implementation){a(F,G.sibling),ce=h(G,W.children||[]),ce.return=F,F=ce;break e}else{a(F,G);break}else n(F,G);G=G.sibling}ce=zd(W,F.mode,ce),ce.return=F,F=ce}return j(F);case U:return xe=W._init,W=xe(W._payload),Ke(F,G,W,ce)}if($(W))return Me(F,G,W,ce);if(q(W)){if(xe=q(W),typeof xe!="function")throw Error(s(150));return W=xe.call(W),Ce(F,G,W,ce)}if(typeof W.then=="function")return Ke(F,G,Yl(W),ce);if(W.$$typeof===E)return Ke(F,G,Tl(F,W),ce);ql(F,W)}return typeof W=="string"&&W!==""||typeof W=="number"||typeof W=="bigint"?(W=""+W,G!==null&&G.tag===6?(a(F,G.sibling),ce=h(G,W),ce.return=F,F=ce):(a(F,G),ce=Od(W,F.mode,ce),ce.return=F,F=ce),j(F)):a(F,G)}return function(F,G,W,ce){try{$s=0;var xe=Ke(F,G,W,ce);return ma=null,xe}catch(Ee){if(Ee===Ds||Ee===Dl)throw Ee;var ze=qt(29,Ee,null,F.mode);return ze.lanes=ce,ze.return=F,ze}finally{}}}var pa=vx(!0),bx=vx(!1),ln=B(null),Mn=null;function Rr(t){var n=t.alternate;Z(pt,pt.current&1),Z(ln,t),Mn===null&&(n===null||ua.current!==null||n.memoizedState!==null)&&(Mn=t)}function wx(t){if(t.tag===22){if(Z(pt,pt.current),Z(ln,t),Mn===null){var n=t.alternate;n!==null&&n.memoizedState!==null&&(Mn=t)}}else Dr()}function Dr(){Z(pt,pt.current),Z(ln,ln.current)}function ar(t){K(ln),Mn===t&&(Mn=null),K(pt)}var pt=B(0);function Gl(t){for(var n=t;n!==null;){if(n.tag===13){var a=n.memoizedState;if(a!==null&&(a=a.dehydrated,a===null||a.data==="$?"||oh(a)))return n}else if(n.tag===19&&n.memoizedProps.revealOrder!==void 0){if((n.flags&128)!==0)return n}else if(n.child!==null){n.child.return=n,n=n.child;continue}if(n===t)break;for(;n.sibling===null;){if(n.return===null||n.return===t)return null;n=n.return}n.sibling.return=n.return,n=n.sibling}return null}function pf(t,n,a,l){n=t.memoizedState,a=a(l,n),a=a==null?n:g({},n,a),t.memoizedState=a,t.lanes===0&&(t.updateQueue.baseState=a)}var gf={enqueueSetState:function(t,n,a){t=t._reactInternals;var l=Zt(),h=Mr(l);h.payload=n,a!=null&&(h.callback=a),n=kr(t,h,l),n!==null&&(Kt(n,t,l),zs(n,t,l))},enqueueReplaceState:function(t,n,a){t=t._reactInternals;var l=Zt(),h=Mr(l);h.tag=1,h.payload=n,a!=null&&(h.callback=a),n=kr(t,h,l),n!==null&&(Kt(n,t,l),zs(n,t,l))},enqueueForceUpdate:function(t,n){t=t._reactInternals;var a=Zt(),l=Mr(a);l.tag=2,n!=null&&(l.callback=n),n=kr(t,l,a),n!==null&&(Kt(n,t,a),zs(n,t,a))}};function Sx(t,n,a,l,h,x,j){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(l,x,j):n.prototype&&n.prototype.isPureReactComponent?!_s(a,l)||!_s(h,x):!0}function Nx(t,n,a,l){t=n.state,typeof n.componentWillReceiveProps=="function"&&n.componentWillReceiveProps(a,l),typeof n.UNSAFE_componentWillReceiveProps=="function"&&n.UNSAFE_componentWillReceiveProps(a,l),n.state!==t&&gf.enqueueReplaceState(n,n.state,null)}function Eo(t,n){var a=n;if("ref"in n){a={};for(var l in n)l!=="ref"&&(a[l]=n[l])}if(t=t.defaultProps){a===n&&(a=g({},a));for(var h in t)a[h]===void 0&&(a[h]=t[h])}return a}var Xl=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var n=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(n))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function Ex(t){Xl(t)}function _x(t){console.error(t)}function jx(t){Xl(t)}function Fl(t,n){try{var a=t.onUncaughtError;a(n.value,{componentStack:n.stack})}catch(l){setTimeout(function(){throw l})}}function Cx(t,n,a){try{var l=t.onCaughtError;l(a.value,{componentStack:a.stack,errorBoundary:n.tag===1?n.stateNode:null})}catch(h){setTimeout(function(){throw h})}}function xf(t,n,a){return a=Mr(a),a.tag=3,a.payload={element:null},a.callback=function(){Fl(t,n)},a}function Ax(t){return t=Mr(t),t.tag=3,t}function Mx(t,n,a,l){var h=a.type.getDerivedStateFromError;if(typeof h=="function"){var x=l.value;t.payload=function(){return h(x)},t.callback=function(){Cx(n,a,l)}}var j=a.stateNode;j!==null&&typeof j.componentDidCatch=="function"&&(t.callback=function(){Cx(n,a,l),typeof h!="function"&&(Br===null?Br=new Set([this]):Br.add(this));var T=l.stack;this.componentDidCatch(l.value,{componentStack:T!==null?T:""})})}function yE(t,n,a,l,h){if(a.flags|=32768,l!==null&&typeof l=="object"&&typeof l.then=="function"){if(n=a.alternate,n!==null&&ks(n,a,h,!0),a=ln.current,a!==null){switch(a.tag){case 13:return Mn===null?Pf():a.alternate===null&&at===0&&(at=3),a.flags&=-257,a.flags|=65536,a.lanes=h,l===qd?a.flags|=16384:(n=a.updateQueue,n===null?a.updateQueue=new Set([l]):n.add(l),Vf(t,l,h)),!1;case 22:return a.flags|=65536,l===qd?a.flags|=16384:(n=a.updateQueue,n===null?(n={transitions:null,markerInstances:null,retryQueue:new Set([l])},a.updateQueue=n):(a=n.retryQueue,a===null?n.retryQueue=new Set([l]):a.add(l)),Vf(t,l,h)),!1}throw Error(s(435,a.tag))}return Vf(t,l,h),Pf(),!1}if(Ye)return n=ln.current,n!==null?((n.flags&65536)===0&&(n.flags|=256),n.flags|=65536,n.lanes=h,l!==Id&&(t=Error(s(422),{cause:l}),Ms(rn(t,a)))):(l!==Id&&(n=Error(s(423),{cause:l}),Ms(rn(n,a))),t=t.current.alternate,t.flags|=65536,h&=-h,t.lanes|=h,l=rn(l,a),h=xf(t.stateNode,l,h),Fd(t,h),at!==4&&(at=2)),!1;var x=Error(s(520),{cause:l});if(x=rn(x,a),Ks===null?Ks=[x]:Ks.push(x),at!==4&&(at=2),n===null)return!0;l=rn(l,a),a=n;do{switch(a.tag){case 3:return a.flags|=65536,t=h&-h,a.lanes|=t,t=xf(a.stateNode,l,t),Fd(a,t),!1;case 1:if(n=a.type,x=a.stateNode,(a.flags&128)===0&&(typeof n.getDerivedStateFromError=="function"||x!==null&&typeof x.componentDidCatch=="function"&&(Br===null||!Br.has(x))))return a.flags|=65536,h&=-h,a.lanes|=h,h=Ax(h),Mx(h,t,a,l),Fd(a,h),!1}a=a.return}while(a!==null);return!1}var kx=Error(s(461)),vt=!1;function Nt(t,n,a,l){n.child=t===null?bx(n,null,a,l):pa(n,t.child,a,l)}function Tx(t,n,a,l,h){a=a.render;var x=n.ref;if("ref"in l){var j={};for(var T in l)T!=="ref"&&(j[T]=l[T])}else j=l;return wo(n),l=Jd(t,n,a,j,x,h),T=ef(),t!==null&&!vt?(tf(t,n,h),sr(t,n,h)):(Ye&&T&&Ld(n),n.flags|=1,Nt(t,n,l,h),n.child)}function Rx(t,n,a,l,h){if(t===null){var x=a.type;return typeof x=="function"&&!Dd(x)&&x.defaultProps===void 0&&a.compare===null?(n.tag=15,n.type=x,Dx(t,n,x,l,h)):(t=Cl(a.type,null,l,n,n.mode,h),t.ref=n.ref,t.return=n,n.child=t)}if(x=t.child,!_f(t,h)){var j=x.memoizedProps;if(a=a.compare,a=a!==null?a:_s,a(j,l)&&t.ref===n.ref)return sr(t,n,h)}return n.flags|=1,t=Jn(x,l),t.ref=n.ref,t.return=n,n.child=t}function Dx(t,n,a,l,h){if(t!==null){var x=t.memoizedProps;if(_s(x,l)&&t.ref===n.ref)if(vt=!1,n.pendingProps=l=x,_f(t,h))(t.flags&131072)!==0&&(vt=!0);else return n.lanes=t.lanes,sr(t,n,h)}return yf(t,n,a,l,h)}function Ox(t,n,a){var l=n.pendingProps,h=l.children,x=t!==null?t.memoizedState:null;if(l.mode==="hidden"){if((n.flags&128)!==0){if(l=x!==null?x.baseLanes|a:a,t!==null){for(h=n.child=t.child,x=0;h!==null;)x=x|h.lanes|h.childLanes,h=h.sibling;n.childLanes=x&~l}else n.childLanes=0,n.child=null;return zx(t,n,l,a)}if((a&536870912)!==0)n.memoizedState={baseLanes:0,cachePool:null},t!==null&&Rl(n,x!==null?x.cachePool:null),x!==null?Dg(n,x):Kd(),wx(n);else return n.lanes=n.childLanes=536870912,zx(t,n,x!==null?x.baseLanes|a:a,a)}else x!==null?(Rl(n,x.cachePool),Dg(n,x),Dr(),n.memoizedState=null):(t!==null&&Rl(n,null),Kd(),Dr());return Nt(t,n,h,a),n.child}function zx(t,n,a,l){var h=Yd();return h=h===null?null:{parent:mt._currentValue,pool:h},n.memoizedState={baseLanes:a,cachePool:h},t!==null&&Rl(n,null),Kd(),wx(n),t!==null&&ks(t,n,l,!0),null}function Zl(t,n){var a=n.ref;if(a===null)t!==null&&t.ref!==null&&(n.flags|=4194816);else{if(typeof a!="function"&&typeof a!="object")throw Error(s(284));(t===null||t.ref!==a)&&(n.flags|=4194816)}}function yf(t,n,a,l,h){return wo(n),a=Jd(t,n,a,l,void 0,h),l=ef(),t!==null&&!vt?(tf(t,n,h),sr(t,n,h)):(Ye&&l&&Ld(n),n.flags|=1,Nt(t,n,a,h),n.child)}function Lx(t,n,a,l,h,x){return wo(n),n.updateQueue=null,a=zg(n,l,a,h),Og(t),l=ef(),t!==null&&!vt?(tf(t,n,x),sr(t,n,x)):(Ye&&l&&Ld(n),n.flags|=1,Nt(t,n,a,x),n.child)}function Hx(t,n,a,l,h){if(wo(n),n.stateNode===null){var x=aa,j=a.contextType;typeof j=="object"&&j!==null&&(x=Mt(j)),x=new a(l,x),n.memoizedState=x.state!==null&&x.state!==void 0?x.state:null,x.updater=gf,n.stateNode=x,x._reactInternals=n,x=n.stateNode,x.props=l,x.state=n.memoizedState,x.refs={},Gd(n),j=a.contextType,x.context=typeof j=="object"&&j!==null?Mt(j):aa,x.state=n.memoizedState,j=a.getDerivedStateFromProps,typeof j=="function"&&(pf(n,a,j,l),x.state=n.memoizedState),typeof a.getDerivedStateFromProps=="function"||typeof x.getSnapshotBeforeUpdate=="function"||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(j=x.state,typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount(),j!==x.state&&gf.enqueueReplaceState(x,x.state,null),Hs(n,l,x,h),Ls(),x.state=n.memoizedState),typeof x.componentDidMount=="function"&&(n.flags|=4194308),l=!0}else if(t===null){x=n.stateNode;var T=n.memoizedProps,V=Eo(a,T);x.props=V;var J=x.context,le=a.contextType;j=aa,typeof le=="object"&&le!==null&&(j=Mt(le));var ue=a.getDerivedStateFromProps;le=typeof ue=="function"||typeof x.getSnapshotBeforeUpdate=="function",T=n.pendingProps!==T,le||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(T||J!==j)&&Nx(n,x,l,j),Ar=!1;var re=n.memoizedState;x.state=re,Hs(n,l,x,h),Ls(),J=n.memoizedState,T||re!==J||Ar?(typeof ue=="function"&&(pf(n,a,ue,l),J=n.memoizedState),(V=Ar||Sx(n,a,V,l,re,J,j))?(le||typeof x.UNSAFE_componentWillMount!="function"&&typeof x.componentWillMount!="function"||(typeof x.componentWillMount=="function"&&x.componentWillMount(),typeof x.UNSAFE_componentWillMount=="function"&&x.UNSAFE_componentWillMount()),typeof x.componentDidMount=="function"&&(n.flags|=4194308)):(typeof x.componentDidMount=="function"&&(n.flags|=4194308),n.memoizedProps=l,n.memoizedState=J),x.props=l,x.state=J,x.context=j,l=V):(typeof x.componentDidMount=="function"&&(n.flags|=4194308),l=!1)}else{x=n.stateNode,Xd(t,n),j=n.memoizedProps,le=Eo(a,j),x.props=le,ue=n.pendingProps,re=x.context,J=a.contextType,V=aa,typeof J=="object"&&J!==null&&(V=Mt(J)),T=a.getDerivedStateFromProps,(J=typeof T=="function"||typeof x.getSnapshotBeforeUpdate=="function")||typeof x.UNSAFE_componentWillReceiveProps!="function"&&typeof x.componentWillReceiveProps!="function"||(j!==ue||re!==V)&&Nx(n,x,l,V),Ar=!1,re=n.memoizedState,x.state=re,Hs(n,l,x,h),Ls();var oe=n.memoizedState;j!==ue||re!==oe||Ar||t!==null&&t.dependencies!==null&&kl(t.dependencies)?(typeof T=="function"&&(pf(n,a,T,l),oe=n.memoizedState),(le=Ar||Sx(n,a,le,l,re,oe,V)||t!==null&&t.dependencies!==null&&kl(t.dependencies))?(J||typeof x.UNSAFE_componentWillUpdate!="function"&&typeof x.componentWillUpdate!="function"||(typeof x.componentWillUpdate=="function"&&x.componentWillUpdate(l,oe,V),typeof x.UNSAFE_componentWillUpdate=="function"&&x.UNSAFE_componentWillUpdate(l,oe,V)),typeof x.componentDidUpdate=="function"&&(n.flags|=4),typeof x.getSnapshotBeforeUpdate=="function"&&(n.flags|=1024)):(typeof x.componentDidUpdate!="function"||j===t.memoizedProps&&re===t.memoizedState||(n.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||j===t.memoizedProps&&re===t.memoizedState||(n.flags|=1024),n.memoizedProps=l,n.memoizedState=oe),x.props=l,x.state=oe,x.context=V,l=le):(typeof x.componentDidUpdate!="function"||j===t.memoizedProps&&re===t.memoizedState||(n.flags|=4),typeof x.getSnapshotBeforeUpdate!="function"||j===t.memoizedProps&&re===t.memoizedState||(n.flags|=1024),l=!1)}return x=l,Zl(t,n),l=(n.flags&128)!==0,x||l?(x=n.stateNode,a=l&&typeof a.getDerivedStateFromError!="function"?null:x.render(),n.flags|=1,t!==null&&l?(n.child=pa(n,t.child,null,h),n.child=pa(n,null,a,h)):Nt(t,n,a,h),n.memoizedState=x.state,t=n.child):t=sr(t,n,h),t}function Ix(t,n,a,l){return As(),n.flags|=256,Nt(t,n,a,l),n.child}var vf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function bf(t){return{baseLanes:t,cachePool:_g()}}function wf(t,n,a){return t=t!==null?t.childLanes&~a:0,n&&(t|=cn),t}function Bx(t,n,a){var l=n.pendingProps,h=!1,x=(n.flags&128)!==0,j;if((j=x)||(j=t!==null&&t.memoizedState===null?!1:(pt.current&2)!==0),j&&(h=!0,n.flags&=-129),j=(n.flags&32)!==0,n.flags&=-33,t===null){if(Ye){if(h?Rr(n):Dr(),Ye){var T=ot,V;if(V=T){e:{for(V=T,T=An;V.nodeType!==8;){if(!T){T=null;break e}if(V=yn(V.nextSibling),V===null){T=null;break e}}T=V}T!==null?(n.memoizedState={dehydrated:T,treeContext:go!==null?{id:er,overflow:tr}:null,retryLane:536870912,hydrationErrors:null},V=qt(18,null,null,0),V.stateNode=T,V.return=n,n.child=V,Rt=n,ot=null,V=!0):V=!1}V||vo(n)}if(T=n.memoizedState,T!==null&&(T=T.dehydrated,T!==null))return oh(T)?n.lanes=32:n.lanes=536870912,null;ar(n)}return T=l.children,l=l.fallback,h?(Dr(),h=n.mode,T=Kl({mode:"hidden",children:T},h),l=po(l,h,a,null),T.return=n,l.return=n,T.sibling=l,n.child=T,h=n.child,h.memoizedState=bf(a),h.childLanes=wf(t,j,a),n.memoizedState=vf,l):(Rr(n),Sf(n,T))}if(V=t.memoizedState,V!==null&&(T=V.dehydrated,T!==null)){if(x)n.flags&256?(Rr(n),n.flags&=-257,n=Nf(t,n,a)):n.memoizedState!==null?(Dr(),n.child=t.child,n.flags|=128,n=null):(Dr(),h=l.fallback,T=n.mode,l=Kl({mode:"visible",children:l.children},T),h=po(h,T,a,null),h.flags|=2,l.return=n,h.return=n,l.sibling=h,n.child=l,pa(n,t.child,null,a),l=n.child,l.memoizedState=bf(a),l.childLanes=wf(t,j,a),n.memoizedState=vf,n=h);else if(Rr(n),oh(T)){if(j=T.nextSibling&&T.nextSibling.dataset,j)var J=j.dgst;j=J,l=Error(s(419)),l.stack="",l.digest=j,Ms({value:l,source:null,stack:null}),n=Nf(t,n,a)}else if(vt||ks(t,n,a,!1),j=(a&t.childLanes)!==0,vt||j){if(j=Je,j!==null&&(l=a&-a,l=(l&42)!==0?1:ls(l),l=(l&(j.suspendedLanes|a))!==0?0:l,l!==0&&l!==V.retryLane))throw V.retryLane=l,oa(t,l),Kt(j,t,l),kx;T.data==="$?"||Pf(),n=Nf(t,n,a)}else T.data==="$?"?(n.flags|=192,n.child=t.child,n=null):(t=V.treeContext,ot=yn(T.nextSibling),Rt=n,Ye=!0,yo=null,An=!1,t!==null&&(an[sn++]=er,an[sn++]=tr,an[sn++]=go,er=t.id,tr=t.overflow,go=n),n=Sf(n,l.children),n.flags|=4096);return n}return h?(Dr(),h=l.fallback,T=n.mode,V=t.child,J=V.sibling,l=Jn(V,{mode:"hidden",children:l.children}),l.subtreeFlags=V.subtreeFlags&65011712,J!==null?h=Jn(J,h):(h=po(h,T,a,null),h.flags|=2),h.return=n,l.return=n,l.sibling=h,n.child=l,l=h,h=n.child,T=t.child.memoizedState,T===null?T=bf(a):(V=T.cachePool,V!==null?(J=mt._currentValue,V=V.parent!==J?{parent:J,pool:J}:V):V=_g(),T={baseLanes:T.baseLanes|a,cachePool:V}),h.memoizedState=T,h.childLanes=wf(t,j,a),n.memoizedState=vf,l):(Rr(n),a=t.child,t=a.sibling,a=Jn(a,{mode:"visible",children:l.children}),a.return=n,a.sibling=null,t!==null&&(j=n.deletions,j===null?(n.deletions=[t],n.flags|=16):j.push(t)),n.child=a,n.memoizedState=null,a)}function Sf(t,n){return n=Kl({mode:"visible",children:n},t.mode),n.return=t,t.child=n}function Kl(t,n){return t=qt(22,t,null,n),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function Nf(t,n,a){return pa(n,t.child,null,a),t=Sf(n,n.pendingProps.children),t.flags|=2,n.memoizedState=null,t}function Ux(t,n,a){t.lanes|=n;var l=t.alternate;l!==null&&(l.lanes|=n),Ud(t.return,n,a)}function Ef(t,n,a,l,h){var x=t.memoizedState;x===null?t.memoizedState={isBackwards:n,rendering:null,renderingStartTime:0,last:l,tail:a,tailMode:h}:(x.isBackwards=n,x.rendering=null,x.renderingStartTime=0,x.last=l,x.tail=a,x.tailMode=h)}function Px(t,n,a){var l=n.pendingProps,h=l.revealOrder,x=l.tail;if(Nt(t,n,l.children,a),l=pt.current,(l&2)!==0)l=l&1|2,n.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=n.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&Ux(t,a,n);else if(t.tag===19)Ux(t,a,n);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===n)break e;for(;t.sibling===null;){if(t.return===null||t.return===n)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}l&=1}switch(Z(pt,l),h){case"forwards":for(a=n.child,h=null;a!==null;)t=a.alternate,t!==null&&Gl(t)===null&&(h=a),a=a.sibling;a=h,a===null?(h=n.child,n.child=null):(h=a.sibling,a.sibling=null),Ef(n,!1,h,a,x);break;case"backwards":for(a=null,h=n.child,n.child=null;h!==null;){if(t=h.alternate,t!==null&&Gl(t)===null){n.child=h;break}t=h.sibling,h.sibling=a,a=h,h=t}Ef(n,!0,a,null,x);break;case"together":Ef(n,!1,null,null,void 0);break;default:n.memoizedState=null}return n.child}function sr(t,n,a){if(t!==null&&(n.dependencies=t.dependencies),Ir|=n.lanes,(a&n.childLanes)===0)if(t!==null){if(ks(t,n,a,!1),(a&n.childLanes)===0)return null}else return null;if(t!==null&&n.child!==t.child)throw Error(s(153));if(n.child!==null){for(t=n.child,a=Jn(t,t.pendingProps),n.child=a,a.return=n;t.sibling!==null;)t=t.sibling,a=a.sibling=Jn(t,t.pendingProps),a.return=n;a.sibling=null}return n.child}function _f(t,n){return(t.lanes&n)!==0?!0:(t=t.dependencies,!!(t!==null&&kl(t)))}function vE(t,n,a){switch(n.tag){case 3:ae(n,n.stateNode.containerInfo),Cr(n,mt,t.memoizedState.cache),As();break;case 27:case 5:ge(n);break;case 4:ae(n,n.stateNode.containerInfo);break;case 10:Cr(n,n.type,n.memoizedProps.value);break;case 13:var l=n.memoizedState;if(l!==null)return l.dehydrated!==null?(Rr(n),n.flags|=128,null):(a&n.child.childLanes)!==0?Bx(t,n,a):(Rr(n),t=sr(t,n,a),t!==null?t.sibling:null);Rr(n);break;case 19:var h=(t.flags&128)!==0;if(l=(a&n.childLanes)!==0,l||(ks(t,n,a,!1),l=(a&n.childLanes)!==0),h){if(l)return Px(t,n,a);n.flags|=128}if(h=n.memoizedState,h!==null&&(h.rendering=null,h.tail=null,h.lastEffect=null),Z(pt,pt.current),l)break;return null;case 22:case 23:return n.lanes=0,Ox(t,n,a);case 24:Cr(n,mt,t.memoizedState.cache)}return sr(t,n,a)}function $x(t,n,a){if(t!==null)if(t.memoizedProps!==n.pendingProps)vt=!0;else{if(!_f(t,a)&&(n.flags&128)===0)return vt=!1,vE(t,n,a);vt=(t.flags&131072)!==0}else vt=!1,Ye&&(n.flags&1048576)!==0&&yg(n,Ml,n.index);switch(n.lanes=0,n.tag){case 16:e:{t=n.pendingProps;var l=n.elementType,h=l._init;if(l=h(l._payload),n.type=l,typeof l=="function")Dd(l)?(t=Eo(l,t),n.tag=1,n=Hx(null,n,l,t,a)):(n.tag=0,n=yf(null,n,l,t,a));else{if(l!=null){if(h=l.$$typeof,h===M){n.tag=11,n=Tx(null,n,l,t,a);break e}else if(h===I){n.tag=14,n=Rx(null,n,l,t,a);break e}}throw n=z(l)||l,Error(s(306,n,""))}}return n;case 0:return yf(t,n,n.type,n.pendingProps,a);case 1:return l=n.type,h=Eo(l,n.pendingProps),Hx(t,n,l,h,a);case 3:e:{if(ae(n,n.stateNode.containerInfo),t===null)throw Error(s(387));l=n.pendingProps;var x=n.memoizedState;h=x.element,Xd(t,n),Hs(n,l,null,a);var j=n.memoizedState;if(l=j.cache,Cr(n,mt,l),l!==x.cache&&Pd(n,[mt],a,!0),Ls(),l=j.element,x.isDehydrated)if(x={element:l,isDehydrated:!1,cache:j.cache},n.updateQueue.baseState=x,n.memoizedState=x,n.flags&256){n=Ix(t,n,l,a);break e}else if(l!==h){h=rn(Error(s(424)),n),Ms(h),n=Ix(t,n,l,a);break e}else{switch(t=n.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(ot=yn(t.firstChild),Rt=n,Ye=!0,yo=null,An=!0,a=bx(n,null,l,a),n.child=a;a;)a.flags=a.flags&-3|4096,a=a.sibling}else{if(As(),l===h){n=sr(t,n,a);break e}Nt(t,n,l,a)}n=n.child}return n;case 26:return Zl(t,n),t===null?(a=G0(n.type,null,n.pendingProps,null))?n.memoizedState=a:Ye||(a=n.type,t=n.pendingProps,l=uc(fe.current).createElement(a),l[yt]=n,l[At]=t,_t(l,a,t),ct(l),n.stateNode=l):n.memoizedState=G0(n.type,t.memoizedProps,n.pendingProps,t.memoizedState),null;case 27:return ge(n),t===null&&Ye&&(l=n.stateNode=V0(n.type,n.pendingProps,fe.current),Rt=n,An=!0,h=ot,$r(n.type)?(ah=h,ot=yn(l.firstChild)):ot=h),Nt(t,n,n.pendingProps.children,a),Zl(t,n),t===null&&(n.flags|=4194304),n.child;case 5:return t===null&&Ye&&((h=l=ot)&&(l=XE(l,n.type,n.pendingProps,An),l!==null?(n.stateNode=l,Rt=n,ot=yn(l.firstChild),An=!1,h=!0):h=!1),h||vo(n)),ge(n),h=n.type,x=n.pendingProps,j=t!==null?t.memoizedProps:null,l=x.children,th(h,x)?l=null:j!==null&&th(h,j)&&(n.flags|=32),n.memoizedState!==null&&(h=Jd(t,n,dE,null,null,a),ai._currentValue=h),Zl(t,n),Nt(t,n,l,a),n.child;case 6:return t===null&&Ye&&((t=a=ot)&&(a=FE(a,n.pendingProps,An),a!==null?(n.stateNode=a,Rt=n,ot=null,t=!0):t=!1),t||vo(n)),null;case 13:return Bx(t,n,a);case 4:return ae(n,n.stateNode.containerInfo),l=n.pendingProps,t===null?n.child=pa(n,null,l,a):Nt(t,n,l,a),n.child;case 11:return Tx(t,n,n.type,n.pendingProps,a);case 7:return Nt(t,n,n.pendingProps,a),n.child;case 8:return Nt(t,n,n.pendingProps.children,a),n.child;case 12:return Nt(t,n,n.pendingProps.children,a),n.child;case 10:return l=n.pendingProps,Cr(n,n.type,l.value),Nt(t,n,l.children,a),n.child;case 9:return h=n.type._context,l=n.pendingProps.children,wo(n),h=Mt(h),l=l(h),n.flags|=1,Nt(t,n,l,a),n.child;case 14:return Rx(t,n,n.type,n.pendingProps,a);case 15:return Dx(t,n,n.type,n.pendingProps,a);case 19:return Px(t,n,a);case 31:return l=n.pendingProps,a=n.mode,l={mode:l.mode,children:l.children},t===null?(a=Kl(l,a),a.ref=n.ref,n.child=a,a.return=n,n=a):(a=Jn(t.child,l),a.ref=n.ref,n.child=a,a.return=n,n=a),n;case 22:return Ox(t,n,a);case 24:return wo(n),l=Mt(mt),t===null?(h=Yd(),h===null&&(h=Je,x=$d(),h.pooledCache=x,x.refCount++,x!==null&&(h.pooledCacheLanes|=a),h=x),n.memoizedState={parent:l,cache:h},Gd(n),Cr(n,mt,h)):((t.lanes&a)!==0&&(Xd(t,n),Hs(n,null,null,a),Ls()),h=t.memoizedState,x=n.memoizedState,h.parent!==l?(h={parent:l,cache:l},n.memoizedState=h,n.lanes===0&&(n.memoizedState=n.updateQueue.baseState=h),Cr(n,mt,l)):(l=x.cache,Cr(n,mt,l),l!==h.cache&&Pd(n,[mt],a,!0))),Nt(t,n,n.pendingProps.children,a),n.child;case 29:throw n.pendingProps}throw Error(s(156,n.tag))}function ir(t){t.flags|=4}function Vx(t,n){if(n.type!=="stylesheet"||(n.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!W0(n)){if(n=ln.current,n!==null&&((Ue&4194048)===Ue?Mn!==null:(Ue&62914560)!==Ue&&(Ue&536870912)===0||n!==Mn))throw Os=qd,jg;t.flags|=8192}}function Wl(t,n){n!==null&&(t.flags|=4),t.flags&16384&&(n=t.tag!==22?sl():536870912,t.lanes|=n,va|=n)}function Ys(t,n){if(!Ye)switch(t.tailMode){case"hidden":n=t.tail;for(var a=null;n!==null;)n.alternate!==null&&(a=n),n=n.sibling;a===null?t.tail=null:a.sibling=null;break;case"collapsed":a=t.tail;for(var l=null;a!==null;)a.alternate!==null&&(l=a),a=a.sibling;l===null?n||t.tail===null?t.tail=null:t.tail.sibling=null:l.sibling=null}}function nt(t){var n=t.alternate!==null&&t.alternate.child===t.child,a=0,l=0;if(n)for(var h=t.child;h!==null;)a|=h.lanes|h.childLanes,l|=h.subtreeFlags&65011712,l|=h.flags&65011712,h.return=t,h=h.sibling;else for(h=t.child;h!==null;)a|=h.lanes|h.childLanes,l|=h.subtreeFlags,l|=h.flags,h.return=t,h=h.sibling;return t.subtreeFlags|=l,t.childLanes=a,n}function bE(t,n,a){var l=n.pendingProps;switch(Hd(n),n.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return nt(n),null;case 1:return nt(n),null;case 3:return a=n.stateNode,l=null,t!==null&&(l=t.memoizedState.cache),n.memoizedState.cache!==l&&(n.flags|=2048),rr(mt),me(),a.pendingContext&&(a.context=a.pendingContext,a.pendingContext=null),(t===null||t.child===null)&&(Cs(n)?ir(n):t===null||t.memoizedState.isDehydrated&&(n.flags&256)===0||(n.flags|=1024,wg())),nt(n),null;case 26:return a=n.memoizedState,t===null?(ir(n),a!==null?(nt(n),Vx(n,a)):(nt(n),n.flags&=-16777217)):a?a!==t.memoizedState?(ir(n),nt(n),Vx(n,a)):(nt(n),n.flags&=-16777217):(t.memoizedProps!==l&&ir(n),nt(n),n.flags&=-16777217),null;case 27:se(n),a=fe.current;var h=n.type;if(t!==null&&n.stateNode!=null)t.memoizedProps!==l&&ir(n);else{if(!l){if(n.stateNode===null)throw Error(s(166));return nt(n),null}t=te.current,Cs(n)?vg(n):(t=V0(h,l,a),n.stateNode=t,ir(n))}return nt(n),null;case 5:if(se(n),a=n.type,t!==null&&n.stateNode!=null)t.memoizedProps!==l&&ir(n);else{if(!l){if(n.stateNode===null)throw Error(s(166));return nt(n),null}if(t=te.current,Cs(n))vg(n);else{switch(h=uc(fe.current),t){case 1:t=h.createElementNS("http://www.w3.org/2000/svg",a);break;case 2:t=h.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;default:switch(a){case"svg":t=h.createElementNS("http://www.w3.org/2000/svg",a);break;case"math":t=h.createElementNS("http://www.w3.org/1998/Math/MathML",a);break;case"script":t=h.createElement("div"),t.innerHTML=" - + +
    diff --git a/python/packages/devui/dev.md b/python/packages/devui/dev.md index 3d74f199708..838e3536fc7 100644 --- a/python/packages/devui/dev.md +++ b/python/packages/devui/dev.md @@ -9,8 +9,6 @@ git clone https://github.com/microsoft/agent-framework.git cd agent-framework ``` -(or use the latest main branch if merged) - ## 2. Setup Environment Navigate to the Python directory and install dependencies: @@ -47,7 +45,7 @@ AZURE_OPENAI_CHAT_DEPLOYMENT_NAME="your-deployment-name" **Option A: In-Memory Mode (Recommended for quick testing)** ```bash -cd packages/devui/samples +cd samples/getting_started/devui python in_memory_mode.py ``` @@ -56,7 +54,7 @@ This runs a simple example with predefined agents and opens your browser automat **Option B: Directory-Based Discovery** ```bash -cd packages/devui/samples +cd samples/getting_started/devui devui ``` @@ -72,57 +70,91 @@ This launches the UI with all example agents/workflows at http://localhost:8080 You can also test via API calls: +### Single Request + ```bash curl -X POST http://localhost:8080/v1/responses \ -H "Content-Type: application/json" \ -d '{ - "model": "agent-framework", + "model": "weather_agent", + "input": "What is the weather in Seattle?" + }' +``` + +### Multi-turn Conversations + +```bash +# Create a conversation +curl -X POST http://localhost:8080/v1/conversations \ + -H "Content-Type: application/json" \ + -d '{"metadata": {"agent_id": "weather_agent"}}' + +# Returns: {"id": "conv_abc123", ...} + +# Use conversation ID in requests +curl -X POST http://localhost:8080/v1/responses \ + -H "Content-Type: application/json" \ + -d '{ + "model": "weather_agent", "input": "What is the weather in Seattle?", - "extra_body": {"entity_id": "weather_agent"} + "conversation": "conv_abc123" + }' + +# Continue the conversation +curl -X POST http://localhost:8080/v1/responses \ + -H "Content-Type: application/json" \ + -d '{ + "model": "weather_agent", + "input": "How about tomorrow?", + "conversation": "conv_abc123" }' ``` ## API Mapping -Messages and events from agents/workflows are mapped to OpenAI response types in `agent_framework_devui/_mapper.py`. See the mapping table below: - -| Agent Framework Content | OpenAI Event | Type | -| --------------------------------- | ----------------------------------------- | -------- | -| `TextContent` | `ResponseTextDeltaEvent` | Official | -| `TextReasoningContent` | `ResponseReasoningTextDeltaEvent` | Official | -| `FunctionCallContent` | `ResponseFunctionCallArgumentsDeltaEvent` | Official | -| `FunctionResultContent` | `ResponseFunctionResultComplete` | Custom | -| `ErrorContent` | `ResponseErrorEvent` | Official | -| `UsageContent` | `ResponseUsageEventComplete` | Custom | -| `DataContent` | `ResponseTraceEventComplete` | Custom | -| `UriContent` | `ResponseTraceEventComplete` | Custom | -| `HostedFileContent` | `ResponseTraceEventComplete` | Custom | -| `HostedVectorStoreContent` | `ResponseTraceEventComplete` | Custom | -| `FunctionApprovalRequestContent` | Custom event | Custom | -| `FunctionApprovalResponseContent` | Custom event | Custom | -| `WorkflowEvent` | `ResponseWorkflowEventComplete` | Custom | +Agent Framework content types → OpenAI Responses API events (in `_mapper.py`): -## Frontend Development +| Agent Framework Content | OpenAI Event | Status | +| ------------------------------- | ---------------------------------------- | -------- | +| `TextContent` | `response.output_text.delta` | Standard | +| `TextReasoningContent` | `response.reasoning.delta` | Standard | +| `FunctionCallContent` (initial) | `response.output_item.added` | Standard | +| `FunctionCallContent` (args) | `response.function_call_arguments.delta` | Standard | +| `FunctionResultContent` | `response.function_result.complete` | DevUI | +| `ErrorContent` | `response.error` | Standard | +| `UsageContent` | `response.usage.complete` | Extended | +| `WorkflowEvent` | `response.workflow.event` | DevUI | +| `DataContent`, `UriContent` | `response.trace.complete` | DevUI | -To build the frontend: +- **Standard** = OpenAI spec, **Extended** = OpenAI + extra fields, **DevUI** = DevUI-specific + +## Frontend Development ```bash -cd frontend +cd python/packages/devui/frontend yarn install -# Create .env.local with backend URL -echo 'VITE_API_BASE_URL=http://localhost:8000' > .env.local - -# Create .env.production (empty for relative URLs) -echo '' > .env.production - -# Development +# Development (hot reload) yarn dev -# Build (copies to backend) +# Build (copies to backend ui/) yarn build ``` +## Running Tests + +```bash +cd python/packages/devui + +# All tests +pytest tests/ -v + +# Specific suites +pytest tests/test_conversations.py -v # Conversation store +pytest tests/test_server.py -v # API endpoints +pytest tests/test_mapper.py -v # Event mapping +``` + ## Troubleshooting - **Missing API key**: Make sure your `.env` file is in the `python/` directory with valid credentials. Or set environment variables directly in your shell before running DevUI. diff --git a/python/packages/devui/frontend/package.json b/python/packages/devui/frontend/package.json index 049c5322139..59c43b45d4a 100644 --- a/python/packages/devui/frontend/package.json +++ b/python/packages/devui/frontend/package.json @@ -26,7 +26,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "tailwind-merge": "^3.3.1", - "tailwindcss": "^4.1.12" + "tailwindcss": "^4.1.12", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.33.0", diff --git a/python/packages/devui/frontend/src/App.tsx b/python/packages/devui/frontend/src/App.tsx index e606b7335b0..caddc4ffe45 100644 --- a/python/packages/devui/frontend/src/App.tsx +++ b/python/packages/devui/frontend/src/App.tsx @@ -3,92 +3,105 @@ * Features: Entity selection, layout management, debug coordination */ -import { useState, useEffect, useCallback } from "react"; -import { AppHeader } from "@/components/shared/app-header"; -import { DebugPanel } from "@/components/shared/debug-panel"; -import { SettingsModal } from "@/components/shared/settings-modal"; -import { GalleryView } from "@/components/gallery"; -import { AgentView } from "@/components/agent/agent-view"; -import { WorkflowView } from "@/components/workflow/workflow-view"; -import { LoadingState } from "@/components/ui/loading-state"; +import { useEffect, useCallback } from "react"; +import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout"; +import { GalleryView } from "@/components/features/gallery"; +import { AgentView } from "@/components/features/agent"; +import { WorkflowView } from "@/components/features/workflow"; import { Toast } from "@/components/ui/toast"; import { apiClient } from "@/services/api"; -import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react"; -import type { SampleEntity } from "@/data/gallery"; +import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react"; import type { AgentInfo, WorkflowInfo, - AppState, ExtendedResponseStreamEvent, } from "@/types"; import { Button } from "./components/ui/button"; +import { useDevUIStore } from "@/stores"; export default function App() { - const [appState, setAppState] = useState({ - agents: [], - workflows: [], - isLoading: true, - }); - - const [debugEvents, setDebugEvents] = useState( - [] - ); - const [showDebugPanel, setShowDebugPanel] = useState(() => { - const saved = localStorage.getItem("showDebugPanel"); - return saved !== null ? saved === "true" : true; - }); - const [debugPanelWidth, setDebugPanelWidth] = useState(() => { - const savedWidth = localStorage.getItem("debugPanelWidth"); - return savedWidth ? parseInt(savedWidth, 10) : 320; - }); - const [isResizing, setIsResizing] = useState(false); - const [showAboutModal, setShowAboutModal] = useState(false); - const [showGallery, setShowGallery] = useState(false); - const [addingEntityId, setAddingEntityId] = useState(null); - const [errorEntityId, setErrorEntityId] = useState(null); - const [errorMessage, setErrorMessage] = useState(null); - const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false); + // Entity state from Zustand + const agents = useDevUIStore((state) => state.agents); + const workflows = useDevUIStore((state) => state.workflows); + const selectedAgent = useDevUIStore((state) => state.selectedAgent); + const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities); + const entityError = useDevUIStore((state) => state.entityError); + + // Entity actions + const setAgents = useDevUIStore((state) => state.setAgents); + const setWorkflows = useDevUIStore((state) => state.setWorkflows); + const selectEntity = useDevUIStore((state) => state.selectEntity); + const updateAgent = useDevUIStore((state) => state.updateAgent); + const updateWorkflow = useDevUIStore((state) => state.updateWorkflow); + const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities); + const setEntityError = useDevUIStore((state) => state.setEntityError); + + // UI state from Zustand + const showDebugPanel = useDevUIStore((state) => state.showDebugPanel); + const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth); + const debugEvents = useDevUIStore((state) => state.debugEvents); + const isResizing = useDevUIStore((state) => state.isResizing); + + // UI actions + const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel); + const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth); + const addDebugEvent = useDevUIStore((state) => state.addDebugEvent); + const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents); + const setIsResizing = useDevUIStore((state) => state.setIsResizing); + + // Modal state + const showAboutModal = useDevUIStore((state) => state.showAboutModal); + const showGallery = useDevUIStore((state) => state.showGallery); + const showDeployModal = useDevUIStore((state) => state.showDeployModal); + const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast); + + // Modal actions + const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal); + const setShowGallery = useDevUIStore((state) => state.setShowGallery); + const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal); + const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast); // Initialize app - load agents and workflows useEffect(() => { const loadData = async () => { try { - const [agents, workflows] = await Promise.all([ - apiClient.getAgents(), - apiClient.getWorkflows(), - ]); + // Single API call instead of two parallel calls to same endpoint + const { agents: agentList, workflows: workflowList } = await apiClient.getEntities(); + + setAgents(agentList); + setWorkflows(workflowList); // Check if there's an entity_id in the URL const urlParams = new URLSearchParams(window.location.search); const entityId = urlParams.get("entity_id"); - let selectedAgent: AgentInfo | WorkflowInfo | undefined; + let selectedEntity: AgentInfo | WorkflowInfo | undefined; // Try to find entity from URL parameter first if (entityId) { - selectedAgent = - agents.find((a) => a.id === entityId) || - workflows.find((w) => w.id === entityId); + selectedEntity = + agentList.find((a) => a.id === entityId) || + workflowList.find((w) => w.id === entityId); // If entity not found but was requested, show notification - if (!selectedAgent) { + if (!selectedEntity) { setShowEntityNotFoundToast(true); } } // Fallback to first available entity if URL entity not found - if (!selectedAgent) { - selectedAgent = - agents.length > 0 - ? agents[0] - : workflows.length > 0 - ? workflows[0] + if (!selectedEntity) { + selectedEntity = + agentList.length > 0 + ? agentList[0] + : workflowList.length > 0 + ? workflowList[0] : undefined; // Update URL to match actual selected entity (or clear if none) - if (selectedAgent) { + if (selectedEntity) { const url = new URL(window.location.href); - url.searchParams.set("entity_id", selectedAgent.id); + url.searchParams.set("entity_id", selectedEntity.id); window.history.replaceState({}, "", url); } else { // Clear entity_id if no entities available @@ -98,34 +111,44 @@ export default function App() { } } - setAppState((prev) => ({ - ...prev, - agents, - workflows, - selectedAgent, - isLoading: false, - })); + if (selectedEntity) { + selectEntity(selectedEntity); + + // Load full info for the first entity immediately + if (selectedEntity.metadata?.lazy_loaded === false) { + try { + if (selectedEntity.type === "agent") { + const fullAgent = await apiClient.getAgentInfo( + selectedEntity.id + ); + updateAgent(fullAgent); + } else { + const fullWorkflow = await apiClient.getWorkflowInfo( + selectedEntity.id + ); + updateWorkflow(fullWorkflow); + } + } catch (error) { + console.error( + `Failed to load full info for first entity ${selectedEntity.id}:`, + error + ); + } + } + } + + setIsLoadingEntities(false); } catch (error) { console.error("Failed to load agents/workflows:", error); - setAppState((prev) => ({ - ...prev, - error: error instanceof Error ? error.message : "Failed to load data", - isLoading: false, - })); + setEntityError( + error instanceof Error ? error.message : "Failed to load data" + ); + setIsLoadingEntities(false); } }; loadData(); - }, []); - - // Save debug panel state to localStorage - useEffect(() => { - localStorage.setItem("showDebugPanel", showDebugPanel.toString()); - }, [showDebugPanel]); - - useEffect(() => { - localStorage.setItem("debugPanelWidth", debugPanelWidth.toString()); - }, [debugPanelWidth]); + }, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]); // Handle resize drag const handleMouseDown = useCallback( @@ -157,161 +180,43 @@ export default function App() { [debugPanelWidth] ); - // Handle entity selection - const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => { - setAppState((prev) => ({ - ...prev, - selectedAgent: item, - currentThread: undefined, - })); - - // Update URL with selected entity ID - const url = new URL(window.location.href); - url.searchParams.set("entity_id", item.id); - window.history.pushState({}, "", url); - - // Clear debug events when switching entities - setDebugEvents([]); - }, []); + // Handle entity selection - uses Zustand's selectEntity which handles ALL side effects + const handleEntitySelect = useCallback( + async (item: AgentInfo | WorkflowInfo) => { + selectEntity(item); // This clears conversation state, debug events, and updates URL! + + // If entity is sparse (not fully loaded), load full details + if (item.metadata?.lazy_loaded === false) { + try { + if (item.type === "agent") { + const fullAgent = await apiClient.getAgentInfo(item.id); + updateAgent(fullAgent); + } else { + const fullWorkflow = await apiClient.getWorkflowInfo(item.id); + updateWorkflow(fullWorkflow); + } + } catch (error) { + console.error(`Failed to load full info for ${item.id}:`, error); + } + } + }, + [selectEntity, updateAgent, updateWorkflow] + ); // Handle debug events from active view const handleDebugEvent = useCallback( (event: ExtendedResponseStreamEvent | "clear") => { if (event === "clear") { - setDebugEvents([]); + clearDebugEvents(); } else { - setDebugEvents((prev) => [...prev, event]); + addDebugEvent(event); } }, - [] - ); - - // Handle adding sample entity - const handleAddSample = useCallback(async (sample: SampleEntity) => { - setAddingEntityId(sample.id); - setErrorEntityId(null); - setErrorMessage(null); - - try { - // Call backend to fetch and add entity - const newEntity = await apiClient.addEntity(sample.url, { - source: "remote_gallery", - originalUrl: sample.url, - sampleId: sample.id, - }); - - // Convert backend entity to frontend format - const convertedEntity = { - id: newEntity.id, - name: newEntity.name, - description: newEntity.description, - type: newEntity.type, - source: - (newEntity.source as "directory" | "in_memory" | "remote_gallery") || - "remote_gallery", - has_env: false, - module_path: undefined, - }; - - // Update app state - if (newEntity.type === "agent") { - const agentEntity = { - ...convertedEntity, - tools: (newEntity.tools || []).map((tool) => - typeof tool === "string" ? tool : JSON.stringify(tool) - ), - } as AgentInfo; - - setAppState((prev) => ({ - ...prev, - agents: [...prev.agents, agentEntity], - selectedAgent: agentEntity, - })); - - // Update URL with new entity - const url = new URL(window.location.href); - url.searchParams.set("entity_id", agentEntity.id); - window.history.pushState({}, "", url); - } else { - const workflowEntity = { - ...convertedEntity, - executors: (newEntity.tools || []).map((tool) => - typeof tool === "string" ? tool : JSON.stringify(tool) - ), - input_schema: { type: "string" }, - input_type_name: "Input", - start_executor_id: - newEntity.tools && newEntity.tools.length > 0 - ? typeof newEntity.tools[0] === "string" - ? newEntity.tools[0] - : JSON.stringify(newEntity.tools[0]) - : "unknown", - } as WorkflowInfo; - - setAppState((prev) => ({ - ...prev, - workflows: [...prev.workflows, workflowEntity], - selectedAgent: workflowEntity, - })); - - // Update URL with new entity - const url = new URL(window.location.href); - url.searchParams.set("entity_id", workflowEntity.id); - window.history.pushState({}, "", url); - } - - // Close gallery and clear debug events - setShowGallery(false); - setDebugEvents([]); - } catch (error) { - const errMsg = - error instanceof Error ? error.message : "Failed to add sample entity"; - console.error("Failed to add sample entity:", errMsg); - setErrorEntityId(sample.id); - setErrorMessage(errMsg); - } finally { - setAddingEntityId(null); - } - }, []); - - const handleClearError = useCallback(() => { - setErrorEntityId(null); - setErrorMessage(null); - }, []); - - // Handle removing entity - const handleRemoveEntity = useCallback( - async (entityId: string) => { - try { - await apiClient.removeEntity(entityId); - - // Update app state - setAppState((prev) => ({ - ...prev, - agents: prev.agents.filter((a) => a.id !== entityId), - workflows: prev.workflows.filter((w) => w.id !== entityId), - selectedAgent: - prev.selectedAgent?.id === entityId - ? undefined - : prev.selectedAgent, - })); - - // Update URL - clear entity_id if we removed the selected entity - if (appState.selectedAgent?.id === entityId) { - const url = new URL(window.location.href); - url.searchParams.delete("entity_id"); - window.history.pushState({}, "", url); - setDebugEvents([]); - } - } catch (error) { - console.error("Failed to remove entity:", error); - } - }, - [appState.selectedAgent?.id] + [addDebugEvent, clearDebugEvents] ); // Show loading state while initializing - if (appState.isLoading) { + if (isLoadingEntities) { return (
    {/* Top Bar - Skeleton */} @@ -324,17 +229,18 @@ export default function App() { {/* Loading Content */} - +
    +
    +
    Initializing DevUI...
    +
    Loading agents and workflows from your configuration
    +
    +
    ); } // Show error state if loading failed - if (appState.error) { + if (entityError) { return (
    {}} - onRemove={handleRemoveEntity} isLoading={false} onSettingsClick={() => setShowAboutModal(true)} /> @@ -390,14 +295,14 @@ export default function App() {
    {/* Error Details (Collapsible) */} - {appState.error && ( + {entityError && (
    Error details

    - {appState.error} + {entityError}

    )} @@ -422,13 +327,12 @@ export default function App() { return (
    setShowGallery(true)} - isLoading={appState.isLoading} + isLoading={isLoadingEntities} onSettingsClick={() => setShowAboutModal(true)} /> @@ -439,40 +343,28 @@ export default function App() {
    setShowGallery(false)} hasExistingEntities={ - appState.agents.length > 0 || appState.workflows.length > 0 + agents.length > 0 || workflows.length > 0 } />
    - ) : appState.agents.length === 0 && appState.workflows.length === 0 ? ( + ) : agents.length === 0 && workflows.length === 0 ? ( // Empty state - show gallery inline (full width, no debug panel) - + ) : ( <> {/* Left Panel - Main View */}
    - {appState.selectedAgent ? ( - appState.selectedAgent.type === "agent" ? ( + {selectedAgent ? ( + selectedAgent.type === "agent" ? ( ) : ( ) @@ -505,7 +397,7 @@ export default function App() { {/* Right Panel - Debug */}
    setShowDebugPanel(false)} /> + + {/* Deploy Footer - Pinned to bottom */} +
    + +
    ) : ( @@ -536,6 +443,13 @@ export default function App() { {/* Settings Modal */} + {/* Deployment Modal */} + setShowDeployModal(false)} + agentName={selectedAgent?.name} + /> + {/* Toast Notification */} {showEntityNotFoundToast && ( void; - -interface AgentViewProps { - selectedAgent: AgentInfo; - onDebugEvent: DebugEventHandler; -} - -interface MessageBubbleProps { - message: ChatMessage; -} - -function MessageBubble({ message }: MessageBubbleProps) { - const isUser = message.role === "user"; - const isError = message.error; - const Icon = isUser ? User : isError ? AlertCircle : Bot; - - return ( -
    -
    - -
    - -
    -
    - {isError && ( -
    - - - Unable to process request - -
    - )} -
    - -
    -
    - -
    - {new Date(message.timestamp).toLocaleTimeString()} - {!isUser && message.usage && ( - <> - - - {message.usage.total_tokens >= 1000 - ? `${(message.usage.total_tokens / 1000).toFixed(2)}k` - : message.usage.total_tokens}{" "} - tokens - {message.usage.prompt_tokens > 0 && ( - - {" "} - ( - {message.usage.prompt_tokens >= 1000 - ? `${(message.usage.prompt_tokens / 1000).toFixed(1)}k` - : message.usage.prompt_tokens}{" "} - in,{" "} - {message.usage.completion_tokens >= 1000 - ? `${(message.usage.completion_tokens / 1000).toFixed(1)}k` - : message.usage.completion_tokens}{" "} - out) - - )} - - - )} -
    -
    -
    - ); -} - -function TypingIndicator() { - return ( -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} - -export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { - const [chatState, setChatState] = useState({ - messages: [], - isStreaming: false, - }); - const [currentThread, setCurrentThread] = useState( - undefined - ); - const [availableThreads, setAvailableThreads] = useState([]); - const [inputValue, setInputValue] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [attachments, setAttachments] = useState([]); - const [loadingThreads, setLoadingThreads] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - const [dragCounter, setDragCounter] = useState(0); - const [pasteNotification, setPasteNotification] = useState( - null - ); - const [detailsModalOpen, setDetailsModalOpen] = useState(false); - const [threadUsage, setThreadUsage] = useState<{ - total_tokens: number; - message_count: number; - }>({ total_tokens: 0, message_count: 0 }); - - const scrollAreaRef = useRef(null); - const messagesEndRef = useRef(null); - const accumulatedText = useRef(""); - const textareaRef = useRef(null); - const currentMessageUsage = useRef<{ - total_tokens: number; - prompt_tokens: number; - completion_tokens: number; - } | null>(null); - - // Auto-scroll to bottom when new messages arrive - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [chatState.messages, chatState.isStreaming]); - - // Load threads when agent changes - useEffect(() => { - const loadThreads = async () => { - if (!selectedAgent) return; - - setLoadingThreads(true); - try { - const threads = await apiClient.getThreads(selectedAgent.id); - setAvailableThreads(threads); - - // Auto-select the most recent thread if available - if (threads.length > 0) { - const mostRecentThread = threads[0]; // Assuming threads are sorted by creation date (newest first) - setCurrentThread(mostRecentThread); - - // Load messages for the selected thread - try { - const threadMessages = await apiClient.getThreadMessages( - mostRecentThread.id - ); - setChatState({ - messages: threadMessages, - isStreaming: false, - }); - } catch (error) { - console.error("Failed to load thread messages:", error); - setChatState({ - messages: [], - isStreaming: false, - }); - } - } - } catch (error) { - console.error("Failed to load threads:", error); - setAvailableThreads([]); - } finally { - setLoadingThreads(false); - } - }; - - // Clear chat when agent changes - setChatState({ - messages: [], - isStreaming: false, - }); - setCurrentThread(undefined); - accumulatedText.current = ""; - - loadThreads(); - }, [selectedAgent]); - - // Handle file uploads - const handleFilesSelected = async (files: File[]) => { - const newAttachments: AttachmentItem[] = []; - - for (const file of files) { - const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const type = getFileType(file); - - let preview: string | undefined; - if (type === "image") { - preview = await readFileAsDataURL(file); - } - - newAttachments.push({ - id, - file, - preview, - type, - }); - } - - setAttachments((prev) => [...prev, ...newAttachments]); - }; - - const handleRemoveAttachment = (id: string) => { - setAttachments((prev) => prev.filter((att) => att.id !== id)); - }; - - // Drag and drop handlers - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setDragCounter((prev) => prev + 1); - if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { - setIsDragOver(true); - } - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - const newCounter = dragCounter - 1; - setDragCounter(newCounter); - if (newCounter === 0) { - setIsDragOver(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - setDragCounter(0); - - if (isSubmitting || chatState.isStreaming) return; - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - await handleFilesSelected(files); - } - }; - - // Paste handler - const handlePaste = async (e: React.ClipboardEvent) => { - const items = Array.from(e.clipboardData.items); - const files: File[] = []; - let hasProcessedText = false; - const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this - - for (const item of items) { - // Handle pasted images (screenshots) - if (item.type.startsWith("image/")) { - e.preventDefault(); - const blob = item.getAsFile(); - if (blob) { - const timestamp = Date.now(); - files.push( - new File([blob], `screenshot-${timestamp}.png`, { type: blob.type }) - ); - } - } - // Handle text - only process first text item (browsers often duplicate) - else if (item.type === "text/plain" && !hasProcessedText) { - hasProcessedText = true; - - // We need to check the text synchronously to decide whether to prevent default - // Unfortunately, getAsString is async, so we'll prevent default for all text - // and then decide whether to actually create a file or manually insert the text - e.preventDefault(); - - await new Promise((resolve) => { - item.getAsString((text) => { - // Check if text should be converted to file - const lineCount = (text.match(/\n/g) || []).length; - const shouldConvert = - text.length > TEXT_THRESHOLD || - lineCount > 50 || // Many lines suggests logs/data - /^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like - /^<\?xml|^ { - textarea.selectionStart = textarea.selectionEnd = start + text.length; - textarea.focus(); - }, 0); - } - } - resolve(); - }); - }); - } - } - - // Process collected files - if (files.length > 0) { - await handleFilesSelected(files); - - // Show notification with appropriate icon - const message = - files.length === 1 - ? files[0].name.includes("screenshot") - ? "Screenshot added as attachment" - : "Large text converted to file" - : `${files.length} files added`; - - setPasteNotification(message); - setTimeout(() => setPasteNotification(null), 3000); - } - }; - - // Detect file extension from content - const detectFileExtension = (text: string): string => { - const trimmed = text.trim(); - const lines = trimmed.split('\n'); - - // JSON detection - if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json"; - - // XML/HTML detection - if (/^<\?xml|^ 1) return ".tsv"; - - // CSV detection (more strict) - need multiple lines with consistent comma patterns - if (lines.length > 2) { - const commaLines = lines.filter(line => line.includes(',')); - const semicolonLines = lines.filter(line => line.includes(';')); - - // If >50% of lines have commas and it looks tabular - if (commaLines.length > lines.length * 0.5) { - const avgCommas = commaLines.reduce((sum, line) => sum + (line.match(/,/g) || []).length, 0) / commaLines.length; - if (avgCommas >= 2) return ".csv"; - } - - // If >50% of lines have semicolons and it looks tabular - if (semicolonLines.length > lines.length * 0.5) { - const avgSemicolons = semicolonLines.reduce((sum, line) => sum + (line.match(/;/g) || []).length, 0) / semicolonLines.length; - if (avgSemicolons >= 2) return ".csv"; - } - } - - return ".txt"; - }; - - // Helper functions - const getFileType = (file: File): AttachmentItem["type"] => { - if (file.type.startsWith("image/")) return "image"; - if (file.type === "application/pdf") return "pdf"; - if (file.type.startsWith("audio/")) return "audio"; - return "other"; - }; - - const readFileAsDataURL = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); - reader.onerror = reject; - reader.readAsDataURL(file); - }); - }; - - // Handle new thread creation - const handleNewThread = useCallback(async () => { - if (!selectedAgent) return; - - try { - const newThread = await apiClient.createThread(selectedAgent.id); - setCurrentThread(newThread); - setAvailableThreads((prev) => [newThread, ...prev]); - setChatState({ - messages: [], - isStreaming: false, - }); - setThreadUsage({ total_tokens: 0, message_count: 0 }); - accumulatedText.current = ""; - } catch (error) { - console.error("Failed to create thread:", error); - } - }, [selectedAgent]); - - // Handle thread deletion - const handleDeleteThread = useCallback( - async (threadId: string, e?: React.MouseEvent) => { - // Prevent event from bubbling to SelectItem - if (e) { - e.preventDefault(); - e.stopPropagation(); - } - - // Confirm deletion - if (!confirm("Delete this thread? This cannot be undone.")) { - return; - } - - try { - const success = await apiClient.deleteThread(threadId); - if (success) { - // Remove thread from available threads - const updatedThreads = availableThreads.filter((t) => t.id !== threadId); - setAvailableThreads(updatedThreads); - - // If deleted thread was selected, switch to another thread or clear chat - if (currentThread?.id === threadId) { - if (updatedThreads.length > 0) { - // Select the most recent remaining thread - const nextThread = updatedThreads[0]; - setCurrentThread(nextThread); - - // Load messages for the next thread - try { - const threadMessages = await apiClient.getThreadMessages(nextThread.id); - setChatState({ - messages: threadMessages, - isStreaming: false, - }); - } catch (error) { - console.error("Failed to load thread messages:", error); - setChatState({ - messages: [], - isStreaming: false, - }); - } - } else { - // No threads left, clear everything - setCurrentThread(undefined); - setChatState({ - messages: [], - isStreaming: false, - }); - setThreadUsage({ total_tokens: 0, message_count: 0 }); - accumulatedText.current = ""; - } - } - - // Clear debug panel - onDebugEvent("clear"); - } - } catch (error) { - console.error("Failed to delete thread:", error); - alert("Failed to delete thread. Please try again."); - } - }, - [availableThreads, currentThread, onDebugEvent] - ); - - // Handle thread selection - const handleThreadSelect = useCallback( - async (threadId: string) => { - const thread = availableThreads.find((t) => t.id === threadId); - if (!thread) return; - - setCurrentThread(thread); - - // Clear debug panel when switching threads - onDebugEvent("clear"); - - try { - // Load thread messages from backend - const threadMessages = await apiClient.getThreadMessages(threadId); - - setChatState({ - messages: threadMessages, - isStreaming: false, - }); - - // Calculate cumulative usage for this thread - const totalTokens = threadMessages.reduce( - (sum, msg) => sum + (msg.usage?.total_tokens || 0), - 0 - ); - const messageCount = threadMessages.filter( - (msg) => msg.role === "assistant" && msg.usage - ).length; - setThreadUsage({ total_tokens: totalTokens, message_count: messageCount }); - - console.log( - `Restored ${threadMessages.length} messages for thread ${threadId}` - ); - } catch (error) { - console.error("Failed to load thread messages:", error); - // Fallback to clearing messages - setChatState({ - messages: [], - isStreaming: false, - }); - } - - accumulatedText.current = ""; - }, - [availableThreads] - ); - - // Handle message sending - const handleSendMessage = useCallback( - async (request: RunAgentRequest) => { - if (!selectedAgent) return; - - // Extract text and attachments from OpenAI format for UI display - let displayText = ""; - const attachmentContents: import("@/types/agent-framework").Contents[] = - []; - - // Parse OpenAI ResponseInputParam to extract display content - for (const inputItem of request.input) { - if (inputItem.type === "message" && Array.isArray(inputItem.content)) { - for (const contentItem of inputItem.content) { - if (contentItem.type === "input_text") { - displayText += contentItem.text + " "; - } else if (contentItem.type === "input_image") { - attachmentContents.push({ - type: "data", - uri: contentItem.image_url || "", - media_type: "image/png", // Default, should extract from data URI - } as import("@/types/agent-framework").DataContent); - } else if (contentItem.type === "input_file") { - const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`; - // Determine media type from filename - const filename = (contentItem as import("@/types/agent-framework").ResponseInputFileParam).filename || ""; - let mediaType = "application/octet-stream"; - - if (filename.endsWith(".pdf")) mediaType = "application/pdf"; - else if (filename.endsWith(".txt")) mediaType = "text/plain"; - else if (filename.endsWith(".json")) mediaType = "application/json"; - else if (filename.endsWith(".csv")) mediaType = "text/csv"; - else if (filename.endsWith(".html")) mediaType = "text/html"; - else if (filename.endsWith(".md")) mediaType = "text/markdown"; - - attachmentContents.push({ - type: "data", - uri: dataUri, - media_type: mediaType, - } as import("@/types/agent-framework").DataContent); - } - } - } - } - - const userMessageContents: import("@/types/agent-framework").Contents[] = - [ - ...(displayText.trim() - ? [ - { - type: "text", - text: displayText.trim(), - } as import("@/types/agent-framework").TextContent, - ] - : []), - ...attachmentContents, - ]; - - // Add user message to UI state - const userMessage: ChatMessage = { - id: `user-${Date.now()}`, - role: "user", - contents: userMessageContents, - timestamp: new Date().toISOString(), - }; - - setChatState((prev) => ({ - ...prev, - messages: [...prev.messages, userMessage], - isStreaming: true, - })); - - // Create assistant message placeholder - const assistantMessage: ChatMessage = { - id: `assistant-${Date.now()}`, - role: "assistant", - contents: [], - timestamp: new Date().toISOString(), - streaming: true, - }; - - setChatState((prev) => ({ - ...prev, - messages: [...prev.messages, assistantMessage], - })); - - try { - // If no thread selected, create one automatically - let threadToUse = currentThread; - if (!threadToUse) { - try { - threadToUse = await apiClient.createThread(selectedAgent.id); - setCurrentThread(threadToUse); - setAvailableThreads((prev) => [threadToUse!, ...prev]); - } catch (error) { - console.error("Failed to create thread:", error); - } - } - - const apiRequest = { - input: request.input, - thread_id: threadToUse?.id, - }; - - // Clear text accumulator for new response - accumulatedText.current = ""; - - // Clear debug panel events for new agent run - onDebugEvent("clear"); - - // Use OpenAI-compatible API streaming - direct event handling - const streamGenerator = apiClient.streamAgentExecutionOpenAI( - selectedAgent.id, - apiRequest - ); - - for await (const openAIEvent of streamGenerator) { - // Pass all events to debug panel - onDebugEvent(openAIEvent); - - // Handle usage events - if (openAIEvent.type === "response.usage.complete") { - const usageEvent = openAIEvent as import("@/types").ResponseUsageEventComplete; - console.log("📊 Usage event received:", usageEvent.data); - if (usageEvent.data) { - currentMessageUsage.current = { - total_tokens: usageEvent.data.total_tokens || 0, - prompt_tokens: usageEvent.data.prompt_tokens || 0, - completion_tokens: usageEvent.data.completion_tokens || 0, - }; - console.log("📊 Set usage:", currentMessageUsage.current); - } - } - - // Handle error events from the stream - if (openAIEvent.type === "error") { - const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { - message?: string; - }; - const errorMessage = errorEvent.message || "An error occurred"; - - // Update assistant message with error and stop streaming - setChatState((prev) => ({ - ...prev, - isStreaming: false, - messages: prev.messages.map((msg) => - msg.id === assistantMessage.id - ? { - ...msg, - contents: [ - { - type: "text", - text: errorMessage, - }, - ], - streaming: false, - error: true, // Add error flag for styling - } - : msg - ), - })); - return; // Exit stream processing early on error - } - - // Handle text delta events for chat - if ( - openAIEvent.type === "response.output_text.delta" && - "delta" in openAIEvent && - openAIEvent.delta - ) { - accumulatedText.current += openAIEvent.delta; - - // Update assistant message with accumulated content - setChatState((prev) => ({ - ...prev, - messages: prev.messages.map((msg) => - msg.id === assistantMessage.id - ? { - ...msg, - contents: [ - { - type: "text", - text: accumulatedText.current, - }, - ], - } - : msg - ), - })); - } - - // Handle completion/error by detecting when streaming stops - // (Server will close the stream when done, so we'll exit the loop naturally) - } - - // Stream ended - mark as complete and attach usage - const finalUsage = currentMessageUsage.current; - console.log("📊 Stream ended, attaching usage to message:", finalUsage); - - setChatState((prev) => ({ - ...prev, - isStreaming: false, - messages: prev.messages.map((msg) => - msg.id === assistantMessage.id - ? { - ...msg, - streaming: false, - usage: finalUsage || undefined, - } - : msg - ), - })); - - // Update thread-level usage stats - if (finalUsage) { - setThreadUsage((prev) => ({ - total_tokens: prev.total_tokens + finalUsage.total_tokens, - message_count: prev.message_count + 1, - })); - console.log("📊 Updated thread usage"); - } - - // Reset usage for next message - currentMessageUsage.current = null; - } catch (error) { - console.error("Streaming error:", error); - setChatState((prev) => ({ - ...prev, - isStreaming: false, - messages: prev.messages.map((msg) => - msg.id === assistantMessage.id - ? { - ...msg, - contents: [ - { - type: "text", - text: `Error: ${ - error instanceof Error - ? error.message - : "Failed to get response" - }`, - }, - ], - streaming: false, - } - : msg - ), - })); - } - }, - [selectedAgent, currentThread, onDebugEvent] - ); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - if ( - (!inputValue.trim() && attachments.length === 0) || - isSubmitting || - !selectedAgent - ) - return; - - setIsSubmitting(true); - const messageText = inputValue.trim(); - setInputValue(""); - - try { - // Create OpenAI Responses API format - if (attachments.length > 0 || messageText) { - const content: import("@/types/agent-framework").ResponseInputContent[] = - []; - - // Add text content if present - EXACT OpenAI ResponseInputTextParam - if (messageText) { - content.push({ - text: messageText, - type: "input_text", - } as import("@/types/agent-framework").ResponseInputTextParam); - } - - // Add attachments using EXACT OpenAI types - for (const attachment of attachments) { - const dataUri = await readFileAsDataURL(attachment.file); - - if (attachment.file.type.startsWith("image/")) { - // EXACT OpenAI ResponseInputImageParam - content.push({ - detail: "auto", - type: "input_image", - image_url: dataUri, - } as import("@/types/agent-framework").ResponseInputImageParam); - } else if ( - attachment.file.type === "text/plain" && - (attachment.file.name.includes("pasted-text-") || - attachment.file.name.endsWith(".txt") || - attachment.file.name.endsWith(".csv") || - attachment.file.name.endsWith(".json") || - attachment.file.name.endsWith(".html") || - attachment.file.name.endsWith(".md") || - attachment.file.name.endsWith(".tsv")) - ) { - // Convert all text files (from pasted large text) back to input_text - const text = await attachment.file.text(); - content.push({ - text: text, - type: "input_text", - } as import("@/types/agent-framework").ResponseInputTextParam); - } else { - // EXACT OpenAI ResponseInputFileParam for other files - const base64Data = dataUri.split(",")[1]; // Extract base64 part - content.push({ - type: "input_file", - file_data: base64Data, - file_url: dataUri, // Use data URI as the URL - filename: attachment.file.name, - } as import("@/types/agent-framework").ResponseInputFileParam); - } - } - - const openaiInput: import("@/types/agent-framework").ResponseInputParam = - [ - { - type: "message", - role: "user", - content, - }, - ]; - - // Use pure OpenAI format - await handleSendMessage({ - input: openaiInput, - thread_id: currentThread?.id, - }); - } else { - // Simple text message using OpenAI format - const openaiInput: import("@/types/agent-framework").ResponseInputParam = - [ - { - type: "message", - role: "user", - content: [ - { - text: messageText, - type: "input_text", - } as import("@/types/agent-framework").ResponseInputTextParam, - ], - }, - ]; - - await handleSendMessage({ - input: openaiInput, - thread_id: currentThread?.id, - }); - } - - // Clear attachments after sending - setAttachments([]); - } finally { - setIsSubmitting(false); - } - }; - - const canSendMessage = - selectedAgent && - !isSubmitting && - !chatState.isStreaming && - (inputValue.trim() || attachments.length > 0); - - return ( -
    - {/* Header */} -
    -
    -
    -

    -
    - - Chat with {selectedAgent.name || selectedAgent.id} -
    -

    - -
    - - {/* Thread Controls */} -
    - - - - - -
    -
    - - {selectedAgent.description && ( -

    - {selectedAgent.description} -

    - )} -
    - - {/* Messages */} - -
    - {chatState.messages.length === 0 ? ( -
    -
    - Start a conversation with{" "} - {selectedAgent.name || selectedAgent.id} -
    -
    - Type a message below to begin -
    -
    - ) : ( - chatState.messages.map((message) => ( - - )) - )} - - {chatState.isStreaming && !isSubmitting && } - -
    -
    - - - {/* Input */} -
    -
    - {/* Drag overlay */} - {isDragOver && ( -
    -
    -
    - Drop files here -
    -
    - Images, PDFs, and other files -
    -
    -
    - )} - - {/* Attachment gallery */} - {attachments.length > 0 && ( -
    - -
    - )} - - {/* Paste notification */} - {pasteNotification && ( -
    - {pasteNotification.includes("screenshot") ? ( - - ) : ( - - )} - {pasteNotification} -
    - )} - - {/* Input form */} -
    -