mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dmkorolev/agentthreadstorages
This commit is contained in:
@@ -66,9 +66,11 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.66.0-preview" />
|
||||
|
||||
@@ -67,10 +67,17 @@
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
|
||||
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/ModelContextProtocol/">
|
||||
<File Path="samples/GettingStarted/ModelContextProtocol/README.md" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Observability/">
|
||||
<Project Path="samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
|
||||
@@ -123,6 +130,7 @@
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Observability/">
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
|
||||
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" />
|
||||
@@ -137,6 +145,8 @@
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/Catalog/">
|
||||
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
|
||||
// capabilities to an AI agent. The provider runs a search against an external knowledge base
|
||||
// before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
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";
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation and keep a short rolling window of conversation context.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
// The mock search inspects the user's question and returns pre-defined snippets
|
||||
// that resemble documents stored in an external knowledge source.
|
||||
List<TextSearchProvider.TextSearchResult> results = new();
|
||||
|
||||
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context.
|
||||
|
||||
Key features:
|
||||
- Configuring TextSearchProvider with custom search behavior
|
||||
- Running searches before AI invocations to provide relevant context
|
||||
- Managing conversation memory with a rolling window approach
|
||||
- Citing source documents in AI responses
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure OpenAI endpoint configured
|
||||
2. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure OpenAI endpoint
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
|
||||
|
||||
# Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The sample uses a mock search function that demonstrates the RAG pattern:
|
||||
|
||||
1. When the user asks a question, the TextSearchProvider intercepts it
|
||||
2. The search function looks for relevant documents based on the query
|
||||
3. Retrieved documents are injected into the model's context
|
||||
4. The AI responds using both its training and the provided context
|
||||
5. The agent can cite specific source documents in its answers
|
||||
|
||||
The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.).
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to integrate AI agents into a workflow pipeline.
|
||||
// Three translation agents are connected sequentially to create a translation chain:
|
||||
// English → French → Spanish → English, showing how agents can be composed as workflow executors.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
|
||||
// Create agents
|
||||
AIAgent frenchAgent = GetTranslationAgent("French", chatClient);
|
||||
AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient);
|
||||
AIAgent englishAgent = GetTranslationAgent("English", chatClient);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
Workflow workflow = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build();
|
||||
|
||||
// Execute the workflow
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
|
||||
|
||||
// Must send the turn token to trigger the agents.
|
||||
// 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())
|
||||
{
|
||||
if (evt is AgentRunUpdateEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
|
||||
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
@@ -0,0 +1,26 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates the use of AI agents as executors within a workflow.
|
||||
|
||||
This workflow uses three translation agents:
|
||||
1. French Agent - translates input text to French
|
||||
2. Spanish Agent - translates French text to Spanish
|
||||
3. English Agent - translates Spanish text back to English
|
||||
|
||||
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_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
|
||||
@@ -23,7 +23,7 @@ A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
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();
|
||||
AIAgent a2aAgent = agentCard.GetAIAgent();
|
||||
|
||||
// Create the main agent, and provide the a2a agent skills as a function tools.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
|
||||
@@ -125,7 +125,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
|
||||
instructions: "You are a helpful assistant that provides concise and informative responses.",
|
||||
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(SourceName) // enable telemetry at the agent level
|
||||
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
var thread = agent.GetNewThread();
|
||||
@@ -134,6 +134,8 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent.
|
||||
|
||||
// Create a parent span for the entire agent session
|
||||
using var sessionActivity = activitySource.StartActivity("Agent Session");
|
||||
Console.WriteLine($"Trace ID: {sessionActivity?.TraceId} ");
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
sessionActivity?
|
||||
.SetTag("agent.name", "OpenTelemetryDemoAgent")
|
||||
@@ -147,7 +149,7 @@ using (appLogger.BeginScope(new Dictionary<string, object> { ["SessionId"] = ses
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("You: ");
|
||||
Console.Write("You (or 'exit' to quit): ");
|
||||
var userInput = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent.
|
||||
// The sample uses an In-Memory vector store, which can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
|
||||
// The TextSearchProvider runs a search against the vector store via the TextSearchStore before each model invocation and injects the results into the model context.
|
||||
// The TextSearchStore is a sample store implementation that hardcodes a storage schema and uses the vector store to store and retrieve documents.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Agents.AI.Samples;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
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 embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
|
||||
AzureOpenAIClient azureOpenAIClient = new(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential());
|
||||
|
||||
// Create an In-Memory vector store that uses the Azure OpenAI embedding model to generate embeddings.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new()
|
||||
{
|
||||
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
// Create a store that defines a storage schema, and uses the vector store to store and retrieve documents.
|
||||
TextSearchStore textSearchStore = new(vectorStore, "product-and-policy-info", 3072);
|
||||
|
||||
// Upload sample documents into the store.
|
||||
await textSearchStore.UpsertDocumentsAsync(GetSampleDocuments());
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the TextSearchStore.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
{
|
||||
// Here we are limiting the search results to the single top result to demonstrate that we are accurately matching
|
||||
// specific search results for each question, but in a real world case, more results should be used.
|
||||
var searchResults = await textSearchStore.SearchAsync(text, 1, ct);
|
||||
return searchResults.Select(r => new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = r.SourceName,
|
||||
SourceLink = r.SourceLink,
|
||||
Text = r.Text ?? string.Empty,
|
||||
RawRepresentation = r
|
||||
});
|
||||
};
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
};
|
||||
|
||||
// Create the AI agent with the TextSearchProvider as the AI context provider.
|
||||
AIAgent agent = azureOpenAIClient
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
// Produces some sample search documents.
|
||||
// Each one contains a source name and link, which the agent can use to cite sources in its responses.
|
||||
static IEnumerable<TextSearchDocument> GetSampleDocuments()
|
||||
{
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "return-policy-001",
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
};
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "shipping-guide-001",
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
};
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "tent-care-001",
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
};
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a document that can be used for Retrieval Augmented Generation (RAG) that stores textual data.
|
||||
/// </summary>
|
||||
public sealed class TextSearchDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional list of namespaces that the document should belong to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A namespace is a logical grouping of documents, e.g. may include a group id to scope the document to a specific group of users.
|
||||
/// </remarks>
|
||||
public IList<string> Namespaces { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content as text.
|
||||
/// </summary>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional source ID for the document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This ID should be unique within the collection that the document is stored in, and can
|
||||
/// be used to map back to the source artifact for this document.
|
||||
/// If updates need to be made later or the source document was deleted and this document
|
||||
/// also needs to be deleted, this id can be used to find the document again.
|
||||
/// </remarks>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional name for the source document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to provide display names for citation links when the document is referenced as
|
||||
/// part of a response to a query.
|
||||
/// </remarks>
|
||||
public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional link back to the source of the document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to provide citation links when the document is referenced as
|
||||
/// part of a response to a query.
|
||||
/// </remarks>
|
||||
public string? SourceLink { get; set; }
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// A class that allows for easy storage and retrieval of documents in a Vector Store for Retrieval Augmented Generation (RAG).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class provides an opinionated schema for storing documents in a vector store. It is valuable for simple scenarios
|
||||
/// where you want to store text + embedding, or a reference to an external document + embedding without needing to customize the schema.
|
||||
/// If you want to control the schema yourself, use an implementation of <see cref="VectorStoreCollection{TKey, TRecord}"/> directly instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class and its related types are currently provided as a sample implementation, but may be promoted to a first-class supported API in future releases.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class TextSearchStore : IDisposable
|
||||
{
|
||||
#if NET
|
||||
[GeneratedRegex(@"\p{L}+", RegexOptions.IgnoreCase, "en-US")]
|
||||
private static partial Regex AnyLanguageWordRegex();
|
||||
|
||||
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text => AnyLanguageWordRegex().Matches(text).Select(x => x.Value).ToList();
|
||||
#else
|
||||
private static readonly Regex s_anyLanguageWordRegex = new(@"\p{L}+", RegexOptions.Compiled);
|
||||
private static Regex AnyLanguageWordRegex() => s_anyLanguageWordRegex;
|
||||
|
||||
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text =>
|
||||
{
|
||||
List<string> words = new();
|
||||
foreach (Match word in AnyLanguageWordRegex().Matches(text))
|
||||
{
|
||||
words.Add(word.Value);
|
||||
}
|
||||
return words;
|
||||
};
|
||||
#endif
|
||||
|
||||
private readonly VectorStore _vectorStore;
|
||||
private readonly TextSearchStoreOptions _options;
|
||||
private readonly Func<string, ICollection<string>> _wordSegmenter;
|
||||
|
||||
private readonly VectorStoreCollection<object, Dictionary<string, object?>> _vectorStoreRecordCollection;
|
||||
private readonly SemaphoreSlim _collectionInitializationLock = new(1, 1);
|
||||
private bool _collectionInitialized;
|
||||
private bool _disposedValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextSearchStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="vectorStore">The vector store to store and read the memories from.</param>
|
||||
/// <param name="collectionName">The name of the collection in the vector store to store and read the memories from.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the memory embeddings.</param>
|
||||
/// <param name="options">Options to configure the behavior of this class.</param>
|
||||
/// <exception cref="NotSupportedException">Thrown if the key type provided is not supported.</exception>
|
||||
public TextSearchStore(
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
TextSearchStoreOptions? options = default)
|
||||
{
|
||||
// Verify
|
||||
if (vectorStore is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(vectorStore));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(collectionName))
|
||||
{
|
||||
throw new ArgumentException("Collection name cannot be null or whitespace.", nameof(collectionName));
|
||||
}
|
||||
|
||||
if (vectorDimensions < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(vectorDimensions), "Vector dimensions must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options?.KeyType is not null && options.KeyType != typeof(string) && options.KeyType != typeof(Guid))
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported key of type '{options.KeyType.Name}'");
|
||||
}
|
||||
|
||||
if (options?.KeyType is not null && options.KeyType != typeof(string) && options?.UseSourceIdAsPrimaryKey is true)
|
||||
{
|
||||
throw new NotSupportedException($"The {nameof(TextSearchStoreOptions.UseSourceIdAsPrimaryKey)} option can only be used when the key type is 'string'.");
|
||||
}
|
||||
|
||||
// Assign
|
||||
this._vectorStore = vectorStore;
|
||||
this._options = options ?? new TextSearchStoreOptions();
|
||||
this._wordSegmenter = this._options.WordSegmenter ?? s_defaultWordSegmenter;
|
||||
|
||||
// Create a definition so that we can use the dimensions provided at runtime.
|
||||
VectorStoreCollectionDefinition ragDocumentDefinition = new()
|
||||
{
|
||||
Properties = new List<VectorStoreProperty>()
|
||||
{
|
||||
new VectorStoreKeyProperty("Key", this._options.KeyType ?? typeof(string)),
|
||||
new VectorStoreDataProperty("Namespaces", typeof(List<string>)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("SourceId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("Text", typeof(string)) { IsFullTextIndexed = true },
|
||||
new VectorStoreDataProperty("SourceName", typeof(string)),
|
||||
new VectorStoreDataProperty("SourceLink", typeof(string)),
|
||||
new VectorStoreVectorProperty("TextEmbedding", typeof(string), vectorDimensions),
|
||||
}
|
||||
};
|
||||
|
||||
this._vectorStoreRecordCollection = this._vectorStore.GetDynamicCollection(collectionName, ragDocumentDefinition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a batch of text chunks into the vector store.
|
||||
/// </summary>
|
||||
/// <param name="textChunks">The text chunks to upload.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that completes when the documents have been upserted.</returns>
|
||||
public async Task UpsertTextAsync(IEnumerable<string> textChunks, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (textChunks == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(textChunks));
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var storageDocuments = textChunks.Select(textChunk =>
|
||||
{
|
||||
// Without text we cannot generate a vector.
|
||||
if (string.IsNullOrWhiteSpace(textChunk))
|
||||
{
|
||||
throw new ArgumentException("One of the provided text chunks is null.", nameof(textChunks));
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
{ "Key", this.GenerateUniqueKey(null) },
|
||||
{ "Namespaces", new List<string>() },
|
||||
{ "Text", textChunk },
|
||||
{ "TextEmbedding", textChunk },
|
||||
};
|
||||
});
|
||||
|
||||
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a batch of documents into the vector store.
|
||||
/// </summary>
|
||||
/// <param name="documents">The documents to upload.</param>
|
||||
/// <param name="options">Optional options to control the upsert behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that completes when the documents have been upserted.</returns>
|
||||
public async Task UpsertDocumentsAsync(IEnumerable<TextSearchDocument> documents, TextSearchStoreUpsertOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (documents is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(documents));
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var storageDocuments = documents.Select(document =>
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(documents), "One of the provided documents is null.");
|
||||
}
|
||||
|
||||
// Without text we cannot generate a vector.
|
||||
if (string.IsNullOrWhiteSpace(document.Text))
|
||||
{
|
||||
throw new ArgumentException($"The {nameof(TextSearchDocument.Text)} property must be set.", nameof(document));
|
||||
}
|
||||
|
||||
// If we aren't persisting the text, we need a source id or link to refer back to the original document.
|
||||
if (options?.DoNotPersistSourceText is true && string.IsNullOrWhiteSpace(document.SourceId) && string.IsNullOrWhiteSpace(document.SourceLink))
|
||||
{
|
||||
throw new ArgumentException($"Either the {nameof(TextSearchDocument.SourceId)} or {nameof(TextSearchDocument.SourceLink)} properties must be set when the {nameof(TextSearchStoreUpsertOptions.DoNotPersistSourceText)} setting is true.", nameof(document));
|
||||
}
|
||||
|
||||
var key = this.GenerateUniqueKey(this._options.UseSourceIdAsPrimaryKey ?? false ? document.SourceId : null);
|
||||
|
||||
return new Dictionary<string, object?>()
|
||||
{
|
||||
{ "Key", key },
|
||||
{ "Namespaces", document.Namespaces.ToList() },
|
||||
{ "SourceId", document.SourceId },
|
||||
{ "Text", options?.DoNotPersistSourceText is true ? null : document.Text },
|
||||
{ "SourceName", document.SourceName },
|
||||
{ "SourceLink", document.SourceLink },
|
||||
{ "TextEmbedding", document.Text },
|
||||
};
|
||||
});
|
||||
|
||||
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search the database for documents similar to the provided query.
|
||||
/// </summary>
|
||||
/// <param name="query">The text query to find similar documents to.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The search results.</returns>
|
||||
public async Task<IEnumerable<TextSearchDocument>> SearchAsync(string query, int top, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var searchResult = await this.SearchCoreAsync(query, top, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return searchResult.Select(x => new TextSearchDocument()
|
||||
{
|
||||
Namespaces = (List<string>)x["Namespaces"]!,
|
||||
Text = (string?)x["Text"],
|
||||
SourceId = (string?)x["SourceId"],
|
||||
SourceName = (string?)x["SourceName"],
|
||||
SourceLink = (string?)x["SourceLink"],
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal search implementation with hydration of id / link only storage.
|
||||
/// </summary>
|
||||
/// <param name="query">The text query to find similar documents to.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The search results.</returns>
|
||||
private async Task<IEnumerable<Dictionary<string, object?>>> SearchCoreAsync(string query, int top, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Short circuit if the query is empty.
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If the user has not opted out of hybrid search, check if the vector store supports it.
|
||||
var hybridSearchCollection = this._options.UseHybridSearch ?? true ?
|
||||
vectorStoreRecordCollection.GetService(typeof(IKeywordHybridSearchable<Dictionary<string, object?>>)) as IKeywordHybridSearchable<Dictionary<string, object?>> :
|
||||
null;
|
||||
|
||||
// Optional filter to limit the search to a specific namespace.
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = string.IsNullOrWhiteSpace(this._options.SearchNamespace) ? null : x => ((List<string>)x["Namespaces"]!).Contains(this._options.SearchNamespace);
|
||||
|
||||
// Execute a hybrid search if possible, otherwise perform a regular vector search.
|
||||
var searchResult = hybridSearchCollection is null
|
||||
? vectorStoreRecordCollection.SearchAsync(
|
||||
query,
|
||||
top,
|
||||
options: new()
|
||||
{
|
||||
Filter = filter,
|
||||
},
|
||||
cancellationToken: cancellationToken)
|
||||
: hybridSearchCollection.HybridSearchAsync(
|
||||
query,
|
||||
this._wordSegmenter(query),
|
||||
top,
|
||||
options: new()
|
||||
{
|
||||
Filter = filter,
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Retrieve the documents from the search results.
|
||||
List<Dictionary<string, object?>> searchResponseDocs = new();
|
||||
await foreach (var searchResponseDoc in searchResult.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
searchResponseDocs.Add(searchResponseDoc.Record);
|
||||
}
|
||||
|
||||
// Find any source ids and links for which the text needs to be retrieved.
|
||||
var sourceIdsToRetrieve = searchResponseDocs
|
||||
.Where(x => string.IsNullOrWhiteSpace((string?)x["Text"]))
|
||||
.Select(x => new TextSearchStoreOptions.SourceRetrievalRequest((string?)x["SourceId"], (string?)x["SourceLink"]))
|
||||
.ToList();
|
||||
|
||||
// If we have none, we can return early.
|
||||
if (sourceIdsToRetrieve.Count == 0)
|
||||
{
|
||||
return searchResponseDocs;
|
||||
}
|
||||
|
||||
if (this._options.SourceRetrievalCallback is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} option must be set if retrieving documents without stored text.");
|
||||
}
|
||||
|
||||
// Retrieve the source text for the documents that need it.
|
||||
var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false);
|
||||
|
||||
if (retrievalResponses is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} must return a non-null value.");
|
||||
}
|
||||
|
||||
// Update the retrieved documents with the retrieved text.
|
||||
return searchResponseDocs.GroupJoin(
|
||||
retrievalResponses,
|
||||
searchResponseDoc => (searchResponseDoc["SourceId"], searchResponseDoc["SourceLink"]),
|
||||
retrievalResponse => (retrievalResponse.SourceId, retrievalResponse.SourceLink),
|
||||
(searchResponseDoc, textRetrievalResponse) => (searchResponseDoc, textRetrievalResponse))
|
||||
.SelectMany(
|
||||
joinedSet => joinedSet.textRetrievalResponse.DefaultIfEmpty(),
|
||||
(combined, textRetrievalResponse) =>
|
||||
{
|
||||
combined.searchResponseDoc["Text"] = textRetrievalResponse?.Text ?? combined.searchResponseDoc["Text"];
|
||||
return combined.searchResponseDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread safe method to get the collection and ensure that it is created at least once.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The created collection.</returns>
|
||||
private async Task<VectorStoreCollection<object, Dictionary<string, object?>>> EnsureCollectionExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Return immediately if the collection is already created, no need to do any locking in this case.
|
||||
if (this._collectionInitialized)
|
||||
{
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
// Wait on a lock to ensure that only one thread can create the collection.
|
||||
await this._collectionInitializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If multiple threads waited on the lock, and the first already created the collection,
|
||||
// we can return immediately without doing any work in subsequent threads.
|
||||
if (this._collectionInitialized)
|
||||
{
|
||||
this._collectionInitializationLock.Release();
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
// Only the winning thread should reach this point and create the collection.
|
||||
try
|
||||
{
|
||||
await this._vectorStoreRecordCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
this._collectionInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._collectionInitializationLock.Release();
|
||||
}
|
||||
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique key for the RAG document.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">Source id of the source document for this RAG document.</param>
|
||||
/// <returns>A new unique key.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown if the requested key type is not supported.</exception>
|
||||
private object GenerateUniqueKey(string? sourceId)
|
||||
=> this._options.KeyType switch
|
||||
{
|
||||
_ when (this._options.KeyType == null || this._options.KeyType == typeof(string)) && !string.IsNullOrWhiteSpace(sourceId) => sourceId!,
|
||||
_ when this._options.KeyType == null || this._options.KeyType == typeof(string) => Guid.NewGuid().ToString(),
|
||||
_ when this._options.KeyType == typeof(Guid) => Guid.NewGuid(),
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported key of type '{this._options.KeyType.Name}'")
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._vectorStoreRecordCollection.Dispose();
|
||||
this._collectionInitializationLock.Dispose();
|
||||
}
|
||||
|
||||
this._disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Contains options for the <see cref="TextSearchStore"/>.
|
||||
/// </summary>
|
||||
public sealed class TextSearchStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional namespace to pre-filter the possible
|
||||
/// records with when doing a vector search.
|
||||
/// </summary>
|
||||
public string? SearchNamespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the source ID as the primary key for records.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Using the source ID as the primary key allows for easy updates from the source for any changed
|
||||
/// records, since those records can just be upserted again, and will overwrite the previous version
|
||||
/// of the same record.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This setting can only be used when the chosen key type is a string.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Defaults to <c>false</c> if not set.
|
||||
/// </value>
|
||||
public bool? UseSourceIdAsPrimaryKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use hybrid search if it is available for the provided vector store.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <c>true</c> if not set.
|
||||
/// </value>
|
||||
public bool? UseHybridSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a word segmenter function to split search text into separate words for the purposes of hybrid search.
|
||||
/// This will not be used if <see cref="UseHybridSearch"/> is set to <c>false</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to a simple text-character-based segmenter that splits the text by any character that is not a text character.
|
||||
/// </remarks>
|
||||
public Func<string, ICollection<string>>? WordSegmenter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of key to use for records in the text search store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Make sure to pick a key type that is supported by the underlying vector store.
|
||||
/// Note that you have to choose <see cref="string"/> when using <see cref="UseSourceIdAsPrimaryKey"/>.
|
||||
/// </remarks>
|
||||
/// <value>Defaults to <see cref="string"/> if not set. Only <see cref="string"/> and <see cref="Guid"/> is currently supported.</value>
|
||||
public Type? KeyType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional callback to load the source text using the source id or source link
|
||||
/// if the source text is not persisted in the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The response should include the source id or source link, as provided in the request,
|
||||
/// plus the source text loaded from the source.
|
||||
/// </remarks>
|
||||
public Func<List<SourceRetrievalRequest>, Task<IEnumerable<SourceRetrievalResponse>>>? SourceRetrievalCallback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to the <see cref="SourceRetrievalCallback"/>.
|
||||
/// </summary>
|
||||
public sealed class SourceRetrievalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceRetrievalRequest"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source ID of the document to retrieve.</param>
|
||||
/// <param name="sourceLink">The source link of the document to retrieve.</param>
|
||||
public SourceRetrievalRequest(string? sourceId, string? sourceLink)
|
||||
{
|
||||
this.SourceId = sourceId;
|
||||
this.SourceLink = sourceLink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source ID of the document to retrieve.
|
||||
/// </summary>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source link of the document to retrieve.
|
||||
/// </summary>
|
||||
public string? SourceLink { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response from the <see cref="SourceRetrievalCallback"/>.
|
||||
/// </summary>
|
||||
public sealed class SourceRetrievalResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceRetrievalResponse"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The request matching this response.</param>
|
||||
/// <param name="text">The source text that was retrieved.</param>
|
||||
public SourceRetrievalResponse(SourceRetrievalRequest request, string text)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (text == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
}
|
||||
|
||||
this.SourceId = request.SourceId;
|
||||
this.SourceLink = request.SourceLink;
|
||||
this.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source ID of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source link of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string? SourceLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source text of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string Text { get; set; }
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Contains options for <see cref="TextSearchStore.UpsertDocumentsAsync(IEnumerable{TextSearchDocument}, TextSearchStoreUpsertOptions?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
public sealed class TextSearchStoreUpsertOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the source text should be persisted in the database.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="false"/> if not set.
|
||||
/// </value>
|
||||
public bool DoNotPersistSourceText { get; init; }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent.
|
||||
// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
|
||||
// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.Qdrant;
|
||||
using OpenAI;
|
||||
using Qdrant.Client;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md";
|
||||
var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md";
|
||||
|
||||
AzureOpenAIClient azureOpenAIClient = new(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential());
|
||||
|
||||
// Create a Qdrant vector store that uses the Azure OpenAI embedding model to generate embeddings.
|
||||
QdrantClient client = new("localhost");
|
||||
VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new()
|
||||
{
|
||||
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
// Create a collection and upsert some text into it.
|
||||
var documentationCollection = vectorStore.GetCollection<Guid, DocumentationChunk>("documentation");
|
||||
await documentationCollection.EnsureCollectionDeletedAsync(); // Clear out any data from previous runs.
|
||||
await documentationCollection.EnsureCollectionExistsAsync();
|
||||
await UploadDataFromMarkdown(afOverviewUrl, "Microsoft Agent Framework Overview", documentationCollection, 2000, 200);
|
||||
await UploadDataFromMarkdown(afMigrationUrl, "Semantic Kernel to Microsoft Agent Framework Migration Guide", documentationCollection, 2000, 200);
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the collection.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
{
|
||||
List<TextSearchProvider.TextSearchResult> results = [];
|
||||
await foreach (var result in documentationCollection.SearchAsync(text, 5, cancellationToken: ct))
|
||||
{
|
||||
results.Add(new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = result.Record.SourceName,
|
||||
SourceLink = result.Record.SourceLink,
|
||||
Text = result.Record.Text ?? string.Empty,
|
||||
RawRepresentation = result
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
// Use up to 4 recent messages when searching so that searches
|
||||
// still produce valuable results even when the user is referring
|
||||
// back to previous messages in their request.
|
||||
RecentMessageMemoryLimit = 5
|
||||
};
|
||||
|
||||
// Create the AI agent with the TextSearchProvider as the AI context provider.
|
||||
AIAgent agent = azureOpenAIClient
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about SK threads\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! How do I create a thread in Semantic Kernel?", thread));
|
||||
|
||||
// Here we are asking a very vague question when taken out of context,
|
||||
// but since we are including previous messages in our search using RecentMessageMemoryLimit
|
||||
// the RAG search should still produce useful results.
|
||||
Console.WriteLine("\n>> Asking about AF threads\n");
|
||||
Console.WriteLine(await agent.RunAsync("and in Agent Framework?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Contrasting Approaches\n");
|
||||
Console.WriteLine(await agent.RunAsync("Please contrast the two approaches", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about ancestry\n");
|
||||
Console.WriteLine(await agent.RunAsync("What are the predecessors to the Agent Framework?", thread));
|
||||
|
||||
static async Task UploadDataFromMarkdown(string markdownUrl, string sourceName, VectorStoreCollection<Guid, DocumentationChunk> vectorStoreCollection, int chunkSize, int overlap)
|
||||
{
|
||||
// Download the markdown from the given url.
|
||||
using HttpClient client = new();
|
||||
var markdown = await client.GetStringAsync(new Uri(markdownUrl));
|
||||
|
||||
// Chunk it into separate parts with some overlap between chunks
|
||||
var chunks = new List<DocumentationChunk>();
|
||||
for (int i = 0; i < markdown.Length; i += chunkSize)
|
||||
{
|
||||
var chunk = new DocumentationChunk
|
||||
{
|
||||
Key = Guid.NewGuid(),
|
||||
SourceLink = markdownUrl,
|
||||
SourceName = sourceName,
|
||||
Text = markdown.Substring(i, Math.Min(chunkSize + overlap, markdown.Length - i))
|
||||
};
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
// Upsert each chunk into the provided vector store.
|
||||
await vectorStoreCollection.UpsertAsync(chunks);
|
||||
}
|
||||
|
||||
// Data model that defines the database schema we want to use.
|
||||
internal sealed class DocumentationChunk
|
||||
{
|
||||
[VectorStoreKey]
|
||||
public Guid Key { get; set; }
|
||||
[VectorStoreData]
|
||||
public string SourceLink { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
[VectorStoreVector(Dimensions: 3072)]
|
||||
public string Embedding => this.Text;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Agent Framework Retrieval Augmented Generation (RAG) with an external Vector Store with a custom schema
|
||||
|
||||
This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store.
|
||||
It also uses a custom schema for the documents stored in the vector store.
|
||||
This sample uses Qdrant for the vector store, but this can easily be swapped out for any vector store that has a Microsoft.Extensions.VectorStore implementation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint
|
||||
- Both a chat completion and embedding deployment configured in the Azure OpenAI resource
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
|
||||
- An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally.
|
||||
|
||||
**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
|
||||
|
||||
**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Running the sample from the console
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$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
|
||||
$env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-3-large" # Optional, defaults to text-embedding-3-large
|
||||
```
|
||||
|
||||
If the variables are not set, you will be prompted for the values when running the samples.
|
||||
|
||||
To use Qdrant in docker locally, start your Qdrant instance using the default port mappings.
|
||||
|
||||
```powershell
|
||||
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest
|
||||
```
|
||||
|
||||
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 sample from Visual Studio
|
||||
|
||||
Open the solution in Visual Studio and set the 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.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Agent Framework Retrieval Augmented Generation (RAG)
|
||||
|
||||
These samples show how to create an agent with the Agent Framework that uses Retrieval Augmented Generation (RAG) to enhance its responses with information from a knowledge base.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|
||||
|[RAG with external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.|
|
||||
@@ -28,7 +28,9 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
@@ -52,9 +54,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "Contoso Outdoors Return Policy",
|
||||
Link = "https://contoso.com/policies/returns",
|
||||
Value = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,9 +64,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "Contoso Outdoors Shipping Guide",
|
||||
Link = "https://contoso.com/help/shipping",
|
||||
Value = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,9 +74,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "TrailRunner Tent Care Instructions",
|
||||
Link = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Value = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+85
-31
@@ -1,52 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend.
|
||||
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool.
|
||||
// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
|
||||
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
|
||||
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
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";
|
||||
var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4.1-mini";
|
||||
|
||||
// Get a client to create/retrieve server side agents with.
|
||||
var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential());
|
||||
|
||||
// **** MCP Tool with Auto Approval ****
|
||||
// *************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
var mcpTool = new MCPToolDefinition(
|
||||
serverLabel: "microsoft_learn",
|
||||
serverUrl: "https://learn.microsoft.com/api/mcp");
|
||||
mcpTool.AllowedTools.Add("microsoft_docs_search");
|
||||
|
||||
// Create a server side persistent agent with the Azure.AI.Agents.Persistent SDK.
|
||||
var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync(
|
||||
model: model,
|
||||
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.
|
||||
AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id);
|
||||
|
||||
// Create run options to configure the agent invocation.
|
||||
var runOptions = new ChatClientAgentRunOptions()
|
||||
// In this case we allow the tool to always be called without approval.
|
||||
var mcpTool = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
RawRepresentationFactory = (_) => new ThreadAndRunOptions()
|
||||
{
|
||||
ToolResources = new MCPToolResource(serverLabel: "microsoft_learn")
|
||||
{
|
||||
RequireApproval = new MCPApproval("never"),
|
||||
}.ToToolResources()
|
||||
}
|
||||
}
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
|
||||
};
|
||||
|
||||
// Create a server side persistent agent with the mcp tool, and expose it as an AIAgent.
|
||||
AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync(
|
||||
model: model,
|
||||
options: new()
|
||||
{
|
||||
Name = "MicrosoftLearnAgent",
|
||||
Instructions = "You answer questions by searching the Microsoft Learn content only.",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Tools = [mcpTool]
|
||||
},
|
||||
});
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
var response = await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread, runOptions);
|
||||
Console.WriteLine(response);
|
||||
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
|
||||
|
||||
// Cleanup for sample purposes.
|
||||
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
|
||||
// **** MCP Tool with Approval Required ****
|
||||
// *****************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we require approval before the tool can be called.
|
||||
var mcpToolWithApproval = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAsync(
|
||||
model: model,
|
||||
options: new()
|
||||
{
|
||||
Name = "MicrosoftLearnAgentWithApproval",
|
||||
Instructions = "You answer questions by searching the Microsoft Learn content only.",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Tools = [mcpToolWithApproval]
|
||||
},
|
||||
});
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
ServerName: {approvalRequest.ToolCall.ServerName}
|
||||
Name: {approvalRequest.ToolCall.ToolName}
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|---|---|
|
||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool.
|
||||
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
|
||||
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
|
||||
|
||||
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";
|
||||
|
||||
// **** MCP Tool with Auto Approval ****
|
||||
// *************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we allow the tool to always be called without approval.
|
||||
var mcpTool = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgent",
|
||||
tools: [mcpTool]);
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
|
||||
|
||||
// **** MCP Tool with Approval Required ****
|
||||
// *****************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we require approval before the tool can be called.
|
||||
var mcpToolWithApproval = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgentWithApproval",
|
||||
tools: [mcpToolWithApproval]);
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
ServerName: {approvalRequest.ToolCall.ServerName}
|
||||
Name: {approvalRequest.ToolCall.ToolName}
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
@@ -0,0 +1,17 @@
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini
|
||||
```
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentsSample;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concepts workflows as agents, where a workflow can be
|
||||
@@ -61,9 +61,9 @@ public static class Program
|
||||
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
|
||||
{
|
||||
if (update.MessageId is null)
|
||||
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
// skip updates that don't have a message ID
|
||||
// skip updates that don't have a message ID or text
|
||||
continue;
|
||||
}
|
||||
Console.Clear();
|
||||
|
||||
+20
-21
@@ -4,7 +4,7 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentsSample;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
internal static class WorkflowFactory
|
||||
{
|
||||
@@ -41,44 +41,43 @@ internal static class WorkflowFactory
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentStartExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentStartExecutor")
|
||||
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
/// <param name="message">The user message to process</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
// 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, cancellationToken: cancellationToken);
|
||||
// Broadcast the turn token to kick off the agents.
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken);
|
||||
return routeBuilder
|
||||
.AddHandler<List<ChatMessage>>(this.RouteMessages)
|
||||
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
|
||||
}
|
||||
|
||||
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentAggregationExecutor() :
|
||||
Executor<ChatMessage>("ConcurrentAggregationExecutor")
|
||||
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="message">The messages from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.Add(message);
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
|
||||
@@ -97,21 +97,21 @@ internal sealed class ConcurrentStartExecutor() :
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
internal sealed class ConcurrentAggregationExecutor() :
|
||||
Executor<ChatMessage>("ConcurrentAggregationExecutor")
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="message">The messages from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation</returns>
|
||||
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.Add(message);
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Azure.Monitor.OpenTelemetry.Exporter;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample shows how to enable OpenTelemetry observability for workflows when
|
||||
/// using them as <see cref="AIAgent"/>s.
|
||||
///
|
||||
/// In this example, we create a workflow that uses two language agents to process
|
||||
/// input concurrently, one that responds in French and another that responds in English.
|
||||
///
|
||||
/// You will interact with the workflow in an interactive loop, sending messages and receiving
|
||||
/// streaming responses from the workflow as if it were an agent who responds in both languages.
|
||||
///
|
||||
/// OpenTelemetry observability is enabled at multiple levels:
|
||||
/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service.
|
||||
/// 2. At the agent level, capturing telemetry for agent operations.
|
||||
/// 3. At the workflow level, capturing telemetry for workflow execution.
|
||||
///
|
||||
/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to
|
||||
/// Azure Monitor if an Application Insights connection string is provided.
|
||||
///
|
||||
/// Learn how to set up an Aspire dashboard here:
|
||||
/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - Foundational samples should be completed first.
|
||||
/// - This sample uses concurrent processing.
|
||||
/// - An Azure OpenAI endpoint and deployment name.
|
||||
/// - An Application Insights resource for telemetry (optional).
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private const string SourceName = "Workflow.ApplicationInsightsSample";
|
||||
private static readonly ActivitySource s_activitySource = new(SourceName);
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up observability
|
||||
var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
|
||||
var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317";
|
||||
|
||||
var resourceBuilder = ResourceBuilder
|
||||
.CreateDefault()
|
||||
.AddService("WorkflowSample");
|
||||
|
||||
var traceProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry
|
||||
.AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry
|
||||
.AddSource(SourceName);
|
||||
|
||||
traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString))
|
||||
{
|
||||
traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString);
|
||||
}
|
||||
|
||||
using var traceProvider = traceProviderBuilder.Build();
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level
|
||||
.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 workflow and turn it into an agent with OpenTelemetry instrumentation
|
||||
var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName);
|
||||
var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName)
|
||||
{
|
||||
EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses
|
||||
};
|
||||
var thread = agent.GetNewThread();
|
||||
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.Write("User (or 'exit' to quit): ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessInputAsync(agent, thread, input);
|
||||
}
|
||||
|
||||
// Helper method to process user input and display streaming responses. To display
|
||||
// multiple interleaved responses correctly, we buffer updates by message ID and
|
||||
// re-render all messages on each update.
|
||||
static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input)
|
||||
{
|
||||
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
|
||||
{
|
||||
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
// skip updates that don't have a message ID or text
|
||||
continue;
|
||||
}
|
||||
Console.Clear();
|
||||
|
||||
if (!buffer.TryGetValue(update.MessageId, out List<AgentRunResponseUpdate>? value))
|
||||
{
|
||||
value = [];
|
||||
buffer[update.MessageId] = value;
|
||||
}
|
||||
value.Add(update);
|
||||
|
||||
foreach (var (messageId, segments) in buffer)
|
||||
{
|
||||
string combinedText = string.Concat(segments);
|
||||
Console.WriteLine($"{segments[0].AuthorName}: {combinedText}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
internal static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
internal static Workflow GetWorkflow(IChatClient chatClient, string sourceName)
|
||||
{
|
||||
// Create executors
|
||||
var startExecutor = new ConcurrentStartExecutor();
|
||||
var aggregationExecutor = new ConcurrentAggregationExecutor();
|
||||
AIAgent frenchAgent = GetLanguageAgent("French", chatClient, sourceName);
|
||||
AIAgent englishAgent = GetLanguageAgent("English", chatClient, sourceName);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a language agent for the specified target language.
|
||||
/// </summary>
|
||||
/// <param name="targetLanguage">The target language for translation</param>
|
||||
/// <param name="chatClient">The chat client to use for the agent</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>An AIAgent configured for the specified language</returns>
|
||||
private static AIAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient, string sourceName) =>
|
||||
new ChatClientAgent(
|
||||
chatClient,
|
||||
instructions: $"You're a helpful assistant who always responds in {targetLanguage}.",
|
||||
name: $"{targetLanguage}Agent"
|
||||
)
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
return routeBuilder
|
||||
.AddHandler<List<ChatMessage>>(this.RouteMessages)
|
||||
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
|
||||
}
|
||||
|
||||
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}"));
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -20,7 +20,9 @@ public static class Program
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
UppercaseExecutor uppercase = new();
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
@@ -40,23 +42,6 @@ public static class Program
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First executor: converts input text to uppercase.
|
||||
/// </summary>
|
||||
internal sealed class UppercaseExecutor() : Executor<string, string>("UppercaseExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by converting it to uppercase.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to convert</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The input text converted to uppercase</returns>
|
||||
public override ValueTask<string> 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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class Program
|
||||
.Build();
|
||||
|
||||
// Step 2: Configure the sub-workflow as an executor for use in the parent workflow
|
||||
ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow");
|
||||
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("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");
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally
|
||||
2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions
|
||||
3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses
|
||||
4. **Building complex pipelines** - Chaining multiple heterogeneous components together
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -28,7 +27,7 @@ public static class A2AAgentCardExtensions
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
|
||||
public static AIAgent GetAIAgent(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);
|
||||
|
||||
@@ -42,6 +42,6 @@ public static class A2ACardResolverExtensions
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await agentCard.GetAIAgentAsync(httpClient, loggerFactory).ConfigureAwait(false);
|
||||
return agentCard.GetAIAgent(httpClient, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,31 +131,62 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
Environment.NewLine,
|
||||
context.RequestMessages.Where(m => !string.IsNullOrWhiteSpace(m.Text)).Select(m => m.Text));
|
||||
|
||||
var memories = (await this._client.SearchAsync(
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId,
|
||||
queryText,
|
||||
cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var contextInstructions = memories.Count == 0
|
||||
? null
|
||||
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
|
||||
|
||||
if (this._logger is not null)
|
||||
try
|
||||
{
|
||||
this._logger.LogInformation("Mem0AIContextProvider retrieved {Count} memories.", memories.Count);
|
||||
if (contextInstructions is not null)
|
||||
var memories = (await this._client.SearchAsync(
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId,
|
||||
queryText,
|
||||
cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var outputMessageText = memories.Count == 0
|
||||
? null
|
||||
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
|
||||
|
||||
if (this._logger is not null)
|
||||
{
|
||||
this._logger.LogTrace("Mem0AIContextProvider instructions: {Instructions}", contextInstructions);
|
||||
this._logger.LogInformation(
|
||||
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
memories.Count,
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
if (outputMessageText is not null)
|
||||
{
|
||||
this._logger.LogTrace(
|
||||
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
queryText,
|
||||
outputMessageText,
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AIContext
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
|
||||
};
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, contextInstructions)]
|
||||
};
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
return new AIContext();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -166,12 +197,20 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
return; // Do not update memory on failed invocations.
|
||||
}
|
||||
|
||||
// Persist request and response messages after invocation.
|
||||
await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (context.ResponseMessages is not null)
|
||||
try
|
||||
{
|
||||
await this.PersistMessagesAsync(context.ResponseMessages, cancellationToken).ConfigureAwait(false);
|
||||
// Persist request and response messages after invocation.
|
||||
await this.PersistMessagesAsync(context.RequestMessages.Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -19,12 +19,12 @@ internal sealed class WorkflowModelBuilder : IModelBuilder<Func<object?, bool>>
|
||||
Debug.WriteLine($"> CONNECT: {source.Id} => {target.Id}{(condition is null ? string.Empty : " (?)")}");
|
||||
|
||||
this.WorkflowBuilder.AddEdge(
|
||||
GetExecutorIsh(source),
|
||||
GetExecutorIsh(target),
|
||||
GetExecutorBinding(source),
|
||||
GetExecutorBinding(target),
|
||||
condition);
|
||||
}
|
||||
|
||||
private static ExecutorIsh GetExecutorIsh(IModeledAction action) =>
|
||||
private static ExecutorBinding GetExecutorBinding(IModeledAction action) =>
|
||||
action switch
|
||||
{
|
||||
RequestPortAction port => port.RequestPort,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
|
||||
/// </summary>
|
||||
/// <param name="Agent">The AI agent.</param>
|
||||
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
|
||||
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
|
||||
: ExecutorBinding(Throw.IfNull(Agent).Name ?? Throw.IfNull(Agent.Id),
|
||||
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
|
||||
typeof(AIAgentHostExecutor),
|
||||
Agent)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public static partial class AgentWorkflowBuilder
|
||||
// Create a builder that chains the agents together in sequence. The workflow simply begins
|
||||
// with the first agent in the sequence.
|
||||
WorkflowBuilder? builder = null;
|
||||
ExecutorIsh? previous = null;
|
||||
ExecutorBinding? previous = null;
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
|
||||
@@ -125,8 +125,8 @@ public static partial class AgentWorkflowBuilder
|
||||
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
|
||||
// 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 CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
builder.AddFanOutEdge(start, targets: agentExecutors);
|
||||
for (int i = 0; i < agentExecutors.Length; i++)
|
||||
{
|
||||
@@ -141,7 +141,7 @@ public static partial class AgentWorkflowBuilder
|
||||
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
|
||||
(string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
|
||||
|
||||
ExecutorIsh end = endFactory.ConfigureFactory(ConcurrentEndExecutor.ExecutorId);
|
||||
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
|
||||
|
||||
builder.AddFanInEdge(end, sources: accumulators);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ internal sealed record class ExecutorInfo(TypeId ExecutorType, string ExecutorId
|
||||
this.ExecutorType.IsMatch(executor.GetType())
|
||||
&& this.ExecutorId == executor.Id;
|
||||
|
||||
public bool IsMatch(ExecutorRegistration registration) =>
|
||||
this.ExecutorType.IsMatch(registration.ExecutorType)
|
||||
&& this.ExecutorId == registration.Id;
|
||||
public bool IsMatch(ExecutorBinding binding) =>
|
||||
this.ExecutorType.IsMatch(binding.ExecutorType)
|
||||
&& this.ExecutorId == binding.Id;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
internal static class RepresentationExtensions
|
||||
{
|
||||
public static ExecutorInfo ToExecutorInfo(this ExecutorRegistration registration)
|
||||
public static ExecutorInfo ToExecutorInfo(this ExecutorBinding binding)
|
||||
{
|
||||
Throw.IfNull(registration);
|
||||
return new ExecutorInfo(new TypeId(registration.ExecutorType), registration.Id);
|
||||
Throw.IfNull(binding);
|
||||
return new ExecutorInfo(new TypeId(binding.ExecutorType), binding.Id);
|
||||
}
|
||||
|
||||
public static EdgeInfo ToEdgeInfo(this Edge edge)
|
||||
@@ -38,8 +38,8 @@ internal static class RepresentationExtensions
|
||||
Throw.IfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorInfo> executors =
|
||||
workflow.Registrations.Values.ToDictionary(
|
||||
keySelector: registration => registration.Id,
|
||||
workflow.ExecutorBindings.Values.ToDictionary(
|
||||
keySelector: binding => binding.Id,
|
||||
elementSelector: ToExecutorInfo);
|
||||
|
||||
Dictionary<string, List<EdgeInfo>> edges = workflow.Edges.Keys.ToDictionary(
|
||||
|
||||
@@ -47,10 +47,10 @@ internal sealed class WorkflowInfo
|
||||
}
|
||||
|
||||
// Validate the executors
|
||||
if (workflow.Registrations.Count != this.Executors.Count ||
|
||||
if (workflow.ExecutorBindings.Count != this.Executors.Count ||
|
||||
this.Executors.Keys.Any(
|
||||
executorId => workflow.Registrations.TryGetValue(executorId, out ExecutorRegistration? registration)
|
||||
&& !this.Executors[executorId].IsMatch(registration)))
|
||||
executorId => workflow.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding)
|
||||
&& !this.Executors[executorId].IsMatch(binding)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
// TODO: Unwrap the Configured object, just like for SubworkflowBinding
|
||||
internal record ConfiguredExecutorBinding(Configured<Executor> ConfiguredExecutor, Type ExecutorType)
|
||||
: ExecutorBinding(Throw.IfNull(ConfiguredExecutor).Id,
|
||||
ConfiguredExecutor.BoundFactoryAsync,
|
||||
ExecutorType,
|
||||
ConfiguredExecutor.Raw)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance { get; } = ConfiguredExecutor.Raw is Executor;
|
||||
|
||||
protected override async ValueTask<bool> ResetCoreAsync()
|
||||
{
|
||||
if (this.ConfiguredExecutor.Raw is IResettableExecutor resettable)
|
||||
{
|
||||
await resettable.ResetAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// A custom IAsyncEnumerable implementation that reads from a ChannelReader,
|
||||
/// and suppresses OperationCanceledException when the cancellation token is triggered.
|
||||
/// </summary>
|
||||
internal sealed class NonThrowingChannelReaderAsyncEnumerable<T>(ChannelReader<T> reader) : IAsyncEnumerable<T>
|
||||
{
|
||||
private class Enumerator(ChannelReader<T> reader, CancellationToken cancellationToken) : IAsyncEnumerator<T>
|
||||
{
|
||||
private T? _current;
|
||||
public T Current => this._current ?? throw new InvalidOperationException("Enumeration not started.");
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// no-op - the reader should not be disposed.
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves to the next item in the channel.
|
||||
/// </summary>
|
||||
/// <returns>If successful, returns <c>true</c>, otherwise <c>false</c>.</returns>
|
||||
public async ValueTask<bool> MoveNextAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
bool hasData = await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (hasData)
|
||||
{
|
||||
this._current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Swallow cancellation exceptions to prevent throwing from the enumerator
|
||||
// Enables clean cancellation and aligns with the expected behavior of IAsyncEnumerable.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an async enumerator that reads items from the channel.
|
||||
/// If cancellation is requested, the enumeration exits silently without throwing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">An optional cancellation token from the caller.</param>
|
||||
/// <returns>An async enumerator over the channel items.</returns>
|
||||
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
||||
=> new Enumerator(reader, cancellationToken);
|
||||
}
|
||||
@@ -139,11 +139,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Get the current epoch - we'll only respond to completion signals from this epoch or later
|
||||
int myEpoch = Volatile.Read(ref this._completionEpoch) + 1;
|
||||
|
||||
// Simply read from channel - all coordination is handled by Channel infrastructure
|
||||
// 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(cancellationToken).ConfigureAwait(false))
|
||||
// Use custom async enumerable to avoid exceptions on cancellation.
|
||||
NonThrowingChannelReaderAsyncEnumerable<WorkflowEvent> eventStream = new(this._eventChannel.Reader);
|
||||
await foreach (WorkflowEvent evt in eventStream.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Filter out internal signals used for run loop coordination
|
||||
if (evt is InternalHaltSignal completionSignal)
|
||||
|
||||
@@ -27,6 +27,8 @@ public abstract class Executor : IIdentified
|
||||
private static readonly string s_namespace = typeof(Executor).Namespace!;
|
||||
private static readonly ActivitySource s_activitySource = new(s_namespace);
|
||||
|
||||
// TODO: Add overloads for binding with a configuration/options object once the Configured<T> hierarchy goes away.
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the executor with a unique identifier
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the binding information for a workflow executor, including its identifier, factory method, type, and
|
||||
/// optional raw value.
|
||||
/// </summary>
|
||||
/// <param name="Id">The unique identifier for the executor in the workflow.</param>
|
||||
/// <param name="FactoryAsync">A factory function that creates an instance of the executor. The function accepts two string parameters and returns
|
||||
/// a ValueTask containing the created Executor instance.</param>
|
||||
/// <param name="ExecutorType">The type of the executor. Must be a type derived from Executor.</param>
|
||||
/// <param name="RawValue">An optional raw value associated with the binding.</param>
|
||||
public abstract record class ExecutorBinding(string Id, Func<string, ValueTask<Executor>>? FactoryAsync, Type ExecutorType, object? RawValue = null)
|
||||
: IIdentified,
|
||||
IEquatable<IIdentified>,
|
||||
IEquatable<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the binding is a placeholder (i.e., does not have a factory method defined).
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(false, nameof(FactoryAsync))]
|
||||
public bool IsPlaceholder => this.FactoryAsync == null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether the executor created from this binding is a shared instance across all runs.
|
||||
/// </summary>
|
||||
public abstract bool IsSharedInstance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this binding can be used in concurrent runs
|
||||
/// from the same <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
public abstract bool SupportsConcurrentSharedExecution { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this binding can be reset between subsequent
|
||||
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
|
||||
/// cref="SupportsConcurrentSharedExecution"/>.
|
||||
/// </summary>
|
||||
public abstract bool SupportsResetting { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => $"{this.Id}:{(this.IsPlaceholder ? ":<unbound>" : this.ExecutorType.Name)}";
|
||||
|
||||
private Executor CheckId(Executor executor)
|
||||
{
|
||||
if (executor.Id != this.Id)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
|
||||
}
|
||||
|
||||
return executor;
|
||||
}
|
||||
|
||||
internal async ValueTask<Executor> CreateInstanceAsync(string runId)
|
||||
=> !this.IsPlaceholder
|
||||
? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false))
|
||||
: throw new InvalidOperationException(
|
||||
$"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool Equals(ExecutorBinding? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(IIdentified? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(string? other) =>
|
||||
other is not null && other == this.Id;
|
||||
|
||||
internal ValueTask<bool> TryResetAsync()
|
||||
{
|
||||
// Non-shared instances do not need resetting
|
||||
if (!this.IsSharedInstance)
|
||||
{
|
||||
return new(true);
|
||||
}
|
||||
|
||||
// If the executor supports concurrent use, then resetting is a no-op.
|
||||
if (!this.SupportsResetting)
|
||||
{
|
||||
return new(false);
|
||||
}
|
||||
|
||||
return this.ResetCoreAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the executor's shared resources to their initial state. Must be overridden by bindings that support
|
||||
/// resetting.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
protected virtual ValueTask<bool> ResetCoreAsync() => throw new InvalidOperationException("ExecutorBindings that support resetting must override ResetCoreAsync()");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Id.GetHashCode();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an Executor to a <see cref="ExecutorBinding"/>.
|
||||
/// </summary>
|
||||
/// <param name="executor">The Executor instance to convert.</param>
|
||||
public static implicit operator ExecutorBinding(Executor executor) => executor.BindExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a string identifier to an <see cref="ExecutorPlaceholder"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The string identifier to convert to a placeholder.</param>
|
||||
public static implicit operator ExecutorBinding(string id) => new ExecutorPlaceholder(id);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a <see cref="RequestPort "/>to an <see cref="ExecutorBinding"/>.
|
||||
/// </summary>
|
||||
/// <param name="port">The RequestPort instance to convert.</param>
|
||||
public static implicit operator ExecutorBinding(RequestPort port) => port.BindAsExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorBinding"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="agent"></param>
|
||||
public static implicit operator ExecutorBinding(AIAgent agent) => agent.BindAsExecutor();
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring executors and functions as <see cref="ExecutorBinding"/> instances.
|
||||
/// </summary>
|
||||
public static class ExecutorBindingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures an <see cref="Executor"/> instance for use in a workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
/// </remarks>
|
||||
/// <param name="executor">The executor instance.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance wrapping the specified <see cref="Executor"/>.</returns>
|
||||
public static ExecutorBinding BindExecutor(this Executor executor)
|
||||
=> new ExecutorInstanceBinding(executor);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> BindExecutor<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> factoryAsync.BindExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> BindExecutor<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> factoryAsync.BindExecutor(id);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
|
||||
|
||||
return new ConfiguredExecutorBinding(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
=> factoryAsync.BindExecutor(id, options);
|
||||
|
||||
private static ConfiguredExecutorBinding ToBinding<TInput>(this FunctionExecutor<TInput> executor, Delegate raw)
|
||||
=> new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput>));
|
||||
|
||||
private static ConfiguredExecutorBinding ToBinding<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw)
|
||||
=> new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput, TOutput>));
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An ExecutorRegistration instance representing the configured sub-workflow executor.</returns>
|
||||
[Obsolete("Use BindAsExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
=> workflow.BindAsExecutor(id, options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance representing the configured sub-workflow executor.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
=> new SubworkflowBinding(workflow, id, options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, __) => messageHandlerAsync(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, ct) => messageHandlerAsync(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, __) => messageHandler(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, ctx, __) => messageHandler(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, ct) => messageHandler(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, __) => messageHandlerAsync(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, ct) => messageHandlerAsync(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, __) => messageHandler(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, ctx, __) => messageHandler(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, ct) => messageHandler(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based aggregating executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
|
||||
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent instance.</param>
|
||||
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
|
||||
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
|
||||
=> new AIAgentBinding(agent, emitEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="port">The port configuration.</param>
|
||||
/// <param name="allowWrappedRequests">Specifies whether the port should accept requests already wrapped in
|
||||
/// <see cref="ExternalRequest"/>.</param>
|
||||
/// <returns>A <see cref="RequestPortBinding"/> instance that wraps the provided port.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this RequestPort port, bool allowWrappedRequests = true)
|
||||
=> new RequestPortBinding(port, allowWrappedRequests);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for a shared executor instance, including configuration options
|
||||
/// for event emission.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorInstance">The executor instance to bind. Cannot be null.</param>
|
||||
public record ExecutorInstanceBinding(Executor ExecutorInstance)
|
||||
: ExecutorBinding(Throw.IfNull(ExecutorInstance).Id,
|
||||
(_) => new(ExecutorInstance),
|
||||
ExecutorInstance.GetType(),
|
||||
ExecutorInstance)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => this.ExecutorInstance.IsCrossRunShareable;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => this.ExecutorInstance is IResettableExecutor;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> ResetCoreAsync()
|
||||
{
|
||||
if (this.ExecutorInstance is IResettableExecutor resettable)
|
||||
{
|
||||
await resettable.ResetAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring executors and functions as <see cref="ExecutorIsh"/> instances.
|
||||
/// </summary>
|
||||
public static class ExecutorIshConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> ConfigureFactory<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> ConfigureFactory<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
|
||||
|
||||
return new ExecutorIsh(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor), ExecutorIsh.Type.Executor);
|
||||
}
|
||||
|
||||
private static ExecutorIsh ToExecutorIsh<TInput>(this FunctionExecutor<TInput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput>),
|
||||
ExecutorIsh.Type.Function);
|
||||
|
||||
private static ExecutorIsh ToExecutorIsh<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput, TOutput>),
|
||||
ExecutorIsh.Type.Function);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An ExecutorIsh instance representing the configured sub-workflow executor.</returns>
|
||||
public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
{
|
||||
object ownershipToken = new();
|
||||
workflow.TakeOwnership(ownershipToken, subworkflow: true);
|
||||
|
||||
Configured<WorkflowHostExecutor, ExecutorOptions> configured = new(InitHostExecutorAsync, id, options, raw: workflow);
|
||||
return new ExecutorIsh(configured.Super<WorkflowHostExecutor, Executor, ExecutorOptions>(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
|
||||
|
||||
ValueTask<WorkflowHostExecutor> InitHostExecutorAsync(Config<ExecutorOptions> config, string runId)
|
||||
{
|
||||
return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based aggregating executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
|
||||
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tagged union representing an object that can function like an <see cref="Executor"/> in a <see cref="Workflow"/>,
|
||||
/// or a reference to one by ID.
|
||||
/// </summary>
|
||||
public sealed class ExecutorIsh :
|
||||
IIdentified,
|
||||
IEquatable<ExecutorIsh>,
|
||||
IEquatable<IIdentified>,
|
||||
IEquatable<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the <see cref="ExecutorIsh"/>.
|
||||
/// </summary>
|
||||
public enum Type
|
||||
{
|
||||
/// <summary>
|
||||
/// An unbound executor reference, identified only by ID.
|
||||
/// </summary>
|
||||
Unbound,
|
||||
/// <summary>
|
||||
/// An actual <see cref="Executor"/> instance.
|
||||
/// </summary>
|
||||
Executor,
|
||||
/// <summary>
|
||||
/// A function delegate to be wrapped as an executor.
|
||||
/// </summary>
|
||||
Function,
|
||||
/// <summary>
|
||||
/// An <see cref="RequestPort"/> for servicing external requests.
|
||||
/// </summary>
|
||||
RequestPort,
|
||||
/// <summary>
|
||||
/// An <see cref="AIAgent"/> instance.
|
||||
/// </summary>
|
||||
Agent,
|
||||
/// <summary>
|
||||
/// A nested <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
Workflow,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of data contained in this <see cref="ExecutorIsh" /> instance.
|
||||
/// </summary>
|
||||
public Type ExecutorType { get; init; }
|
||||
|
||||
private readonly string? _idValue;
|
||||
|
||||
private readonly Configured<Executor>? _configuredExecutor;
|
||||
private readonly System.Type? _configuredExecutorType;
|
||||
|
||||
internal readonly RequestPort? _requestPortValue;
|
||||
private readonly AIAgent? _aiAgentValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExecutorIsh"/> class as an unbound reference by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for an <see cref="Executor"/> in the <see cref="Workflow"/></param>
|
||||
public ExecutorIsh(string id)
|
||||
{
|
||||
this.ExecutorType = Type.Unbound;
|
||||
this._idValue = Throw.IfNull(id);
|
||||
}
|
||||
|
||||
internal ExecutorIsh(Configured<Executor> configured, System.Type configuredExecutorType, Type type)
|
||||
{
|
||||
this.ExecutorType = type;
|
||||
this._configuredExecutor = configured;
|
||||
this._configuredExecutorType = configuredExecutorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified executor.
|
||||
/// </summary>
|
||||
/// <param name="executor">The executor instance to be wrapped.</param>
|
||||
public ExecutorIsh(Executor executor)
|
||||
{
|
||||
this.ExecutorType = Type.Executor;
|
||||
this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor));
|
||||
this._configuredExecutorType = executor.GetType();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
|
||||
/// </summary>
|
||||
/// <param name="port">The input port to associate to be wrapped.</param>
|
||||
public ExecutorIsh(RequestPort port)
|
||||
{
|
||||
this.ExecutorType = Type.RequestPort;
|
||||
this._requestPortValue = Throw.IfNull(port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified AI agent.
|
||||
/// </summary>
|
||||
/// <param name="aiAgent"></param>
|
||||
public ExecutorIsh(AIAgent aiAgent)
|
||||
{
|
||||
this.ExecutorType = Type.Agent;
|
||||
this._aiAgentValue = Throw.IfNull(aiAgent);
|
||||
}
|
||||
|
||||
internal bool IsUnbound => this.ExecutorType == Type.Unbound;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Id => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
|
||||
Type.Executor => this._configuredExecutor!.Id,
|
||||
Type.RequestPort => this._requestPortValue!.Id,
|
||||
Type.Agent => this._aiAgentValue!.Id,
|
||||
Type.Function => this._configuredExecutor!.Id,
|
||||
Type.Workflow => this._configuredExecutor!.Id,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
internal object? RawData => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => this._idValue,
|
||||
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
Type.RequestPort => this._requestPortValue,
|
||||
Type.Agent => this._aiAgentValue,
|
||||
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registration details for the current executor.
|
||||
/// </summary>
|
||||
/// <remarks>The returned registration depends on the type of the executor. If the executor is unbound, an
|
||||
/// <see cref="InvalidOperationException"/> is thrown. For other executor types, the registration includes the
|
||||
/// appropriate ID, type, and provider based on the executor's configuration.</remarks>
|
||||
internal ExecutorRegistration Registration => new(this.Id, this.RuntimeType, this.ExecutorProvider, this.RawData);
|
||||
|
||||
private System.Type RuntimeType => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
|
||||
Type.Executor => this._configuredExecutorType!,
|
||||
Type.RequestPort => typeof(RequestInfoExecutor),
|
||||
Type.Agent => typeof(AIAgentHostExecutor),
|
||||
Type.Function => this._configuredExecutorType!,
|
||||
Type.Workflow => this._configuredExecutorType!,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets an <see cref="Func{Executor}"/> that can be used to obtain an <see cref="Executor"/> instance
|
||||
/// corresponding to this <see cref="ExecutorIsh"/>.
|
||||
/// </summary>
|
||||
private Func<string, ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
|
||||
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
|
||||
Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)),
|
||||
Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)),
|
||||
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
|
||||
Type.Workflow => this._configuredExecutor!.BoundFactoryAsync,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="Executor"/> instance to an <see cref="ExecutorIsh"/> object.
|
||||
/// </summary>
|
||||
/// <param name="executor">The <see cref="Executor"/> instance to convert to <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(Executor executor) => new(executor);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="RequestPort"/> to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="inputPort">The <see cref="RequestPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="aiAgent">The <see cref="AIAgent"/> to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a string to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="id">The string ID to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(string id) => new(id);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(ExecutorIsh? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(IIdentified? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(string? other) =>
|
||||
other is not null && other == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) =>
|
||||
obj switch
|
||||
{
|
||||
null => false,
|
||||
ExecutorIsh ish => this.Equals(ish),
|
||||
IIdentified identified => this.Equals(identified),
|
||||
string str => this.Equals(str),
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Id.GetHashCode();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => $"'{this.Id}':<unbound>",
|
||||
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})",
|
||||
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
|
||||
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
_ => $"'{this.Id}':<unknown[{this.ExecutorType}]>"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a placeholder entry for an <see cref="ExecutorBinding"/>, identified by a unique ID.
|
||||
/// </summary>
|
||||
/// <param name="Id">The unique identifier for the placeholder registration.</param>
|
||||
public record ExecutorPlaceholder(string Id)
|
||||
: ExecutorBinding(Id,
|
||||
null,
|
||||
typeof(Executor),
|
||||
Id)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
using ExecutorFactoryF = System.Func<string, System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal sealed class ExecutorRegistration(string id, Type executorType, ExecutorFactoryF provider, object? rawData)
|
||||
{
|
||||
public string Id { get; } = Throw.IfNullOrEmpty(id);
|
||||
public Type ExecutorType { get; } = Throw.IfNull(executorType);
|
||||
private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
|
||||
|
||||
public bool IsSharedInstance { get; } = rawData is Executor;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this registration can be reset between subsequent
|
||||
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
|
||||
/// cref="SupportsConcurrent"/>.
|
||||
/// </summary>
|
||||
public bool SupportsResetting { get; } = rawData is Executor &&
|
||||
// Cross-Run Shareable executors are "trivially" resettable, since they
|
||||
// have no on-object state.
|
||||
rawData is IResettableExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this registration can be used in concurrent runs
|
||||
/// from the same <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
public bool SupportsConcurrent { get; } = rawData is not Executor executor || executor.IsCrossRunShareable;
|
||||
|
||||
internal async ValueTask<bool> TryResetAsync()
|
||||
{
|
||||
// Non-shared instances do not need resetting
|
||||
if (!this.IsSharedInstance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable
|
||||
// then we would have returned in the first condition, and if rawData is not an Executor, we would have
|
||||
// returned in the second condition. That only leaves the possibility of rawData is Executor and also
|
||||
// IResettableExecutor.
|
||||
if (this.RawExecutorishData is IResettableExecutor resettableExecutor)
|
||||
{
|
||||
await resettableExecutor.ResetAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal object? RawExecutorishData { get; } = rawData;
|
||||
|
||||
public override string ToString() => $"{this.ExecutorType.Name}({this.Id})";
|
||||
|
||||
private Executor CheckId(Executor executor)
|
||||
{
|
||||
if (executor.Id != this.Id)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
|
||||
}
|
||||
|
||||
return executor;
|
||||
}
|
||||
|
||||
public async ValueTask<Executor> CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
|
||||
}
|
||||
@@ -38,7 +38,9 @@ public class FunctionExecutor<TInput>(string id,
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
|
||||
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync) : this(id, WrapAction(handlerSync))
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
|
||||
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -76,7 +78,9 @@ public class FunctionExecutor<TInput, TOutput>(string id,
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
|
||||
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync) : this(id, WrapFunc(handlerSync))
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
|
||||
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,12 @@ public sealed class GroupChatWorkflowBuilder
|
||||
public Workflow Build()
|
||||
{
|
||||
AIAgent[] agents = this._participants.ToArray();
|
||||
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
|
||||
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
|
||||
(string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
|
||||
|
||||
ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost));
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
|
||||
@@ -73,7 +73,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
|
||||
async Task<Executor> CreateExecutorAsync(string id)
|
||||
{
|
||||
if (!this._workflow.Registrations.TryGetValue(executorId, out var registration))
|
||||
if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration))
|
||||
{
|
||||
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the registration details for a request port, including configuration for allowing wrapped requests.
|
||||
/// </summary>
|
||||
/// <param name="Port">The request port.</param>
|
||||
/// <param name="AllowWrapped">true to allow wrapped requests to be handled by the port; otherwise, false.
|
||||
/// The default is true.</param>
|
||||
public record RequestPortBinding(RequestPort Port, bool AllowWrapped = true)
|
||||
: ExecutorBinding(Throw.IfNull(Port).Id,
|
||||
(_) => new ValueTask<Executor>(new RequestInfoExecutor(Port, AllowWrapped)),
|
||||
typeof(RequestInfoExecutor),
|
||||
Port)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -10,11 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
internal sealed class GroupChatHost(
|
||||
string id,
|
||||
AIAgent[] agents,
|
||||
Dictionary<AIAgent, ExecutorIsh> agentMap,
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap,
|
||||
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
|
||||
{
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
|
||||
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for a subworkflow, including its instance, identifier, and optional
|
||||
/// executor options.
|
||||
/// </summary>
|
||||
/// <param name="WorkflowInstance"></param>
|
||||
/// <param name="Id"></param>
|
||||
/// <param name="ExecutorOptions"></param>
|
||||
public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorOptions? ExecutorOptions = null)
|
||||
: ExecutorBinding(Throw.IfNull(Id),
|
||||
CreateWorkflowExecutorFactory(WorkflowInstance, Id, ExecutorOptions),
|
||||
typeof(WorkflowHostExecutor),
|
||||
WorkflowInstance)
|
||||
{
|
||||
private static Func<string, ValueTask<Executor>> CreateWorkflowExecutorFactory(Workflow workflow, string id, ExecutorOptions? options)
|
||||
{
|
||||
object ownershipToken = new();
|
||||
workflow.TakeOwnership(ownershipToken, subworkflow: true);
|
||||
|
||||
return InitHostExecutorAsync;
|
||||
|
||||
ValueTask<Executor> InitHostExecutorAsync(string runId)
|
||||
{
|
||||
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public sealed class SwitchBuilder
|
||||
{
|
||||
private readonly List<ExecutorIsh> _executors = [];
|
||||
private readonly List<ExecutorBinding> _executors = [];
|
||||
private readonly Dictionary<string, int> _executorIndicies = [];
|
||||
private readonly List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> _caseMap = [];
|
||||
private readonly HashSet<int> _defaultIndicies = [];
|
||||
@@ -30,14 +30,14 @@ public sealed class SwitchBuilder
|
||||
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
|
||||
/// Cannot be null.</param>
|
||||
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(predicate);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<int> indicies = [];
|
||||
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
@@ -60,11 +60,11 @@ public sealed class SwitchBuilder
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
|
||||
public SwitchBuilder WithDefault(params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
@@ -79,7 +79,7 @@ public sealed class SwitchBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source)
|
||||
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding source)
|
||||
{
|
||||
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
|
||||
HashSet<int> defaultIndicies = this._defaultIndicies;
|
||||
|
||||
@@ -69,7 +69,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\\n(Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.Registrations.Keys)
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
@@ -108,7 +108,7 @@ public static class WorkflowVisualizer
|
||||
|
||||
private static void EmitSubWorkflowsDigraph(Workflow workflow, List<string> lines, string indent)
|
||||
{
|
||||
foreach (var kvp in workflow.Registrations)
|
||||
foreach (var kvp in workflow.ExecutorBindings)
|
||||
{
|
||||
var execId = kvp.Key;
|
||||
var registration = kvp.Value;
|
||||
@@ -145,7 +145,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.Registrations.Keys)
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
@@ -264,9 +264,9 @@ public static class WorkflowVisualizer
|
||||
#endif
|
||||
}
|
||||
|
||||
private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [NotNullWhen(true)] out Workflow? workflow)
|
||||
private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(true)] out Workflow? workflow)
|
||||
{
|
||||
if (registration.RawExecutorishData is Workflow subWorkflow)
|
||||
if (binding.RawValue is Workflow subWorkflow)
|
||||
{
|
||||
workflow = subWorkflow;
|
||||
return true;
|
||||
|
||||
@@ -19,7 +19,7 @@ public class Workflow
|
||||
/// <summary>
|
||||
/// A dictionary of executor providers, keyed by executor ID.
|
||||
/// </summary>
|
||||
internal Dictionary<string, ExecutorRegistration> Registrations { get; init; } = [];
|
||||
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
|
||||
|
||||
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
|
||||
internal HashSet<string> OutputExecutors { get; init; } = [];
|
||||
@@ -41,7 +41,7 @@ public class Workflow
|
||||
/// Gets the collection of external request ports, keyed by their ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each port has a corresponding entry in the <see cref="Registrations"/> dictionary.
|
||||
/// Each port has a corresponding entry in the <see cref="ExecutorBindings"/> dictionary.
|
||||
/// </remarks>
|
||||
public Dictionary<string, RequestPortInfo> ReflectPorts()
|
||||
{
|
||||
@@ -66,10 +66,10 @@ public class Workflow
|
||||
/// </summary>
|
||||
public string? Description { get; internal init; }
|
||||
|
||||
internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent);
|
||||
internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution);
|
||||
|
||||
internal IEnumerable<string> NonConcurrentExecutorIds =>
|
||||
this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id);
|
||||
this.ExecutorBindings.Values.Where(r => !r.SupportsConcurrentSharedExecution).Select(r => r.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
|
||||
@@ -86,12 +86,14 @@ public class Workflow
|
||||
}
|
||||
|
||||
private bool _needsReset;
|
||||
private bool HasResettable => this.Registrations.Values.Any(registration => registration.SupportsResetting);
|
||||
private bool HasResettableExecutors =>
|
||||
this.ExecutorBindings.Values.Any(registration => registration.SupportsResetting);
|
||||
|
||||
private async ValueTask<bool> TryResetExecutorRegistrationsAsync()
|
||||
{
|
||||
if (this.HasResettable)
|
||||
if (this.HasResettableExecutors)
|
||||
{
|
||||
foreach (ExecutorRegistration registration in this.Registrations.Values)
|
||||
foreach (ExecutorBinding registration in this.ExecutorBindings.Values)
|
||||
{
|
||||
// TryResetAsync returns true if the executor does not need resetting
|
||||
if (!await registration.TryResetAsync().ConfigureAwait(false))
|
||||
@@ -158,7 +160,7 @@ public class Workflow
|
||||
});
|
||||
}
|
||||
|
||||
this._needsReset = this.HasResettable;
|
||||
this._needsReset = this.HasResettableExecutors;
|
||||
this._ownedAsSubworkflow = subworkflow;
|
||||
}
|
||||
|
||||
@@ -188,7 +190,7 @@ public class Workflow
|
||||
/// a <see cref="ProtocolDescriptor"/> the protocol this <see cref="Workflow"/> follows.</returns>
|
||||
public async ValueTask<ProtocolDescriptor> DescribeProtocolAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ExecutorRegistration startExecutorRegistration = this.Registrations[this.StartExecutorId];
|
||||
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
|
||||
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
|
||||
.ConfigureAwait(false);
|
||||
return startExecutor.DescribeProtocol();
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <remarks>Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out
|
||||
/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow.
|
||||
/// All executors must be bound by calling into <see cref="BindExecutor"/> if they were intially specified as
|
||||
/// <see cref="ExecutorIsh.Type.Unbound"/>.</remarks>
|
||||
/// <see cref="ExecutorBinding.IsPlaceholder"/>.</remarks>
|
||||
public class WorkflowBuilder
|
||||
{
|
||||
private readonly record struct EdgeConnection(string SourceId, string TargetId)
|
||||
@@ -28,11 +28,11 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
private int _edgeCount;
|
||||
private readonly Dictionary<string, ExecutorRegistration> _executors = [];
|
||||
private readonly Dictionary<string, ExecutorBinding> _executors = [];
|
||||
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
|
||||
private readonly HashSet<string> _unboundExecutors = [];
|
||||
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
|
||||
private readonly Dictionary<string, RequestPort> _inputPorts = [];
|
||||
private readonly Dictionary<string, RequestPort> _requestPorts = [];
|
||||
private readonly HashSet<string> _outputExecutors = [];
|
||||
|
||||
private readonly string _startExecutorId;
|
||||
@@ -46,57 +46,56 @@ public class WorkflowBuilder
|
||||
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
|
||||
/// </summary>
|
||||
/// <param name="start">The executor that defines the starting point of the workflow. Cannot be null.</param>
|
||||
public WorkflowBuilder(ExecutorIsh start)
|
||||
public WorkflowBuilder(ExecutorBinding start)
|
||||
{
|
||||
this._startExecutorId = this.Track(start).Id;
|
||||
}
|
||||
|
||||
private ExecutorIsh Track(ExecutorIsh executorish)
|
||||
private ExecutorBinding Track(ExecutorBinding registration)
|
||||
{
|
||||
// If the executor is unbound, create an entry for it, unless it already exists.
|
||||
// Otherwise, update the entry for it, and remove the unbound tag
|
||||
if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id))
|
||||
if (registration.IsPlaceholder && !this._executors.ContainsKey(registration.Id))
|
||||
{
|
||||
// If this is an unbound executor, we need to track it separately
|
||||
this._unboundExecutors.Add(executorish.Id);
|
||||
this._unboundExecutors.Add(registration.Id);
|
||||
}
|
||||
else if (!executorish.IsUnbound)
|
||||
else if (!registration.IsPlaceholder)
|
||||
{
|
||||
ExecutorRegistration incoming = executorish.Registration;
|
||||
// If there is already a bound executor with this ID, we need to validate (to best efforts)
|
||||
// that the two are matching (at least based on type)
|
||||
if (this._executors.TryGetValue(executorish.Id, out ExecutorRegistration? existing))
|
||||
if (this._executors.TryGetValue(registration.Id, out ExecutorBinding? existing))
|
||||
{
|
||||
if (existing.ExecutorType != incoming.ExecutorType)
|
||||
if (existing.ExecutorType != registration.ExecutorType)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {incoming.ExecutorType.Name}) is already bound.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {registration.ExecutorType.Name}) is already bound.");
|
||||
}
|
||||
|
||||
if (existing.RawExecutorishData is not null &&
|
||||
!ReferenceEquals(existing.RawExecutorishData, incoming.RawExecutorishData))
|
||||
if (existing.RawValue is not null &&
|
||||
!ReferenceEquals(existing.RawValue, registration.RawValue))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but different instance is already bound.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but different instance is already bound.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this._executors[executorish.Id] = executorish.Registration;
|
||||
if (this._unboundExecutors.Contains(executorish.Id))
|
||||
this._executors[registration.Id] = registration;
|
||||
if (this._unboundExecutors.Contains(registration.Id))
|
||||
{
|
||||
this._unboundExecutors.Remove(executorish.Id);
|
||||
this._unboundExecutors.Remove(registration.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort)
|
||||
if (registration is RequestPortBinding portRegistration)
|
||||
{
|
||||
RequestPort port = executorish._requestPortValue!;
|
||||
this._inputPorts[port.Id] = port;
|
||||
RequestPort port = portRegistration.Port;
|
||||
this._requestPorts[port.Id] = port;
|
||||
}
|
||||
|
||||
return executorish;
|
||||
return registration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,9 +105,9 @@ public class WorkflowBuilder
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors)
|
||||
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
|
||||
{
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
this._outputExecutors.Add(this.Track(executor).Id);
|
||||
}
|
||||
@@ -139,21 +138,21 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
|
||||
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
|
||||
/// </summary>
|
||||
/// <param name="executor">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
|
||||
/// <param name="registration">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the specified executor is already bound or does not exist in the workflow.</exception>
|
||||
public WorkflowBuilder BindExecutor(Executor executor)
|
||||
public WorkflowBuilder BindExecutor(ExecutorBinding registration)
|
||||
{
|
||||
if (!this._unboundExecutors.Contains(executor.Id))
|
||||
if (Throw.IfNull(registration) is ExecutorPlaceholder)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " +
|
||||
"You must provide a concrete executor instance or registration.");
|
||||
}
|
||||
|
||||
this._executors[executor.Id] = new ExecutorIsh(executor).Registration;
|
||||
this._unboundExecutors.Remove(executor.Id);
|
||||
this.Track(registration);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ public class WorkflowBuilder
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
|
||||
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
|
||||
=> this.AddEdge<object>(source, target, null, idempotent);
|
||||
|
||||
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
|
||||
@@ -236,7 +235,7 @@ public class WorkflowBuilder
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? condition = null, bool idempotent = false)
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
|
||||
{
|
||||
// Add an edge from source to target with an optional condition.
|
||||
// This is a low-level builder method that does not enforce any specific executor type.
|
||||
@@ -273,7 +272,7 @@ public class WorkflowBuilder
|
||||
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable<ExecutorBinding> targets)
|
||||
=> this.AddFanOutEdge<object>(source, null, targets);
|
||||
|
||||
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
|
||||
@@ -305,7 +304,7 @@ public class WorkflowBuilder
|
||||
/// If null, messages will route to all targets.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorBinding> targets)
|
||||
{
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
@@ -339,7 +338,7 @@ public class WorkflowBuilder
|
||||
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
|
||||
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
|
||||
{
|
||||
Throw.IfNull(target);
|
||||
Throw.IfNull(sources);
|
||||
@@ -398,9 +397,9 @@ public class WorkflowBuilder
|
||||
|
||||
var workflow = new Workflow(this._startExecutorId, this._name, this._description)
|
||||
{
|
||||
Registrations = this._executors,
|
||||
ExecutorBindings = this._executors,
|
||||
Edges = this._edges,
|
||||
Ports = this._inputPorts,
|
||||
Ports = this._requestPorts,
|
||||
OutputExecutors = this._outputExecutors
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
|
||||
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
|
||||
|
||||
/// <summary>
|
||||
@@ -38,7 +38,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// all messages of type <typeparamref name="TMessage"/> will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class WorkflowBuilderExtensions
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
if (executors is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
@@ -68,7 +68,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
@@ -77,7 +77,7 @@ public static class WorkflowBuilderExtensions
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
if (executors is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
@@ -102,7 +102,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
|
||||
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
@@ -139,7 +139,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor representing the external system or process to connect. Cannot be null.</param>
|
||||
/// <param name="portId">The unique identifier for the input port that will handle the external call. Cannot be null.</param>
|
||||
/// <returns>The original workflow builder instance with the external call added.</returns>
|
||||
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string portId)
|
||||
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string portId)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
@@ -160,7 +160,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor that determines the branching condition for the switch. Cannot be null.</param>
|
||||
/// <param name="configureSwitch">An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null.</param>
|
||||
/// <returns>The workflow builder instance with the configured switch step added.</returns>
|
||||
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action<SwitchBuilder> configureSwitch)
|
||||
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorBinding source, Action<SwitchBuilder> configureSwitch)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Data;
|
||||
/// <para>
|
||||
/// The provider supports two behaviors controlled via <see cref="TextSearchProviderOptions.SearchTime"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional instructions.</description></item>
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional messages.</description></item>
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling"/> – Exposes a function tool that the model may invoke to retrieve contextual information when needed.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -45,6 +45,7 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
private readonly AITool[] _tools;
|
||||
private readonly Queue<string> _recentMessagesText;
|
||||
private readonly TextSearchProviderOptions _options;
|
||||
private readonly List<ChatRole> _recentMessageRolesIncluded;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
|
||||
@@ -60,6 +61,7 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
|
||||
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
|
||||
this._recentMessagesText = new();
|
||||
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
|
||||
|
||||
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
|
||||
this._tools =
|
||||
@@ -91,6 +93,7 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
this._options = options ?? new();
|
||||
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
|
||||
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
|
||||
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
|
||||
|
||||
List<string>? restoredMessages = null;
|
||||
|
||||
@@ -119,12 +122,13 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
|
||||
{
|
||||
// Expose the search tool for on-demand invocation.
|
||||
return new AIContext { Tools = this._tools }; // No automatic instructions injection.
|
||||
return new AIContext { Tools = this._tools }; // No automatic message injection.
|
||||
}
|
||||
|
||||
// Aggregate text from memory + current request messages.
|
||||
var sbInput = new StringBuilder();
|
||||
foreach (var messageText in this._recentMessagesText)
|
||||
var requestMessagesText = context.RequestMessages.Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text);
|
||||
foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText))
|
||||
{
|
||||
if (sbInput.Length > 0)
|
||||
{
|
||||
@@ -133,38 +137,35 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
sbInput.Append(messageText);
|
||||
}
|
||||
|
||||
foreach (var message in context.RequestMessages)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message?.Text))
|
||||
{
|
||||
if (sbInput.Length > 0)
|
||||
{
|
||||
sbInput.Append('\n');
|
||||
}
|
||||
sbInput.Append(message.Text);
|
||||
}
|
||||
}
|
||||
string input = sbInput.ToString();
|
||||
|
||||
// Search
|
||||
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
|
||||
if (materialized.Count == 0)
|
||||
try
|
||||
{
|
||||
this._logger?.LogWarning("TextSearchProvider: No search results found.");
|
||||
// Search
|
||||
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
|
||||
if (materialized.Count == 0)
|
||||
{
|
||||
return new AIContext();
|
||||
}
|
||||
|
||||
// Format search results
|
||||
string formatted = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error");
|
||||
return new AIContext();
|
||||
}
|
||||
|
||||
// Format search results
|
||||
string formatted = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{Input}\nContext Instructions:{Instructions}", input, formatted);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, formatted)]
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -183,7 +184,12 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
|
||||
var messagesText = context.RequestMessages
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Where(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrWhiteSpace(m.Text))
|
||||
.Where(m =>
|
||||
this._recentMessageRolesIncluded.Contains(m.Role) &&
|
||||
!string.IsNullOrWhiteSpace(m.Text) &&
|
||||
// Filter out any messages that were added by this class in InvokingAsync, since we don't want
|
||||
// a feedback loop where previous search results are used to find new search results.
|
||||
(m.AdditionalProperties == null || m.AdditionalProperties.TryGetValue("IsTextSearchProviderOutput", out bool isTextSearchProviderOutput) == false || !isTextSearchProviderOutput))
|
||||
.Select(m => m.Text)
|
||||
.ToList();
|
||||
if (messagesText.Count > limit)
|
||||
@@ -232,16 +238,16 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
{
|
||||
var results = await this._searchAsync(userQuestion, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
|
||||
string formatted = this.FormatResults(materialized);
|
||||
string outputText = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nContext Instructions:{Instructions}", userQuestion, formatted);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText);
|
||||
|
||||
return formatted;
|
||||
return outputText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats search results into an instructions string for model consumption.
|
||||
/// Formats search results into an output string for model consumption.
|
||||
/// </summary>
|
||||
/// <param name="results">The results.</param>
|
||||
/// <returns>Formatted string (may be empty).</returns>
|
||||
@@ -262,15 +268,15 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
var result = results[i];
|
||||
if (!string.IsNullOrWhiteSpace(result.Name))
|
||||
if (!string.IsNullOrWhiteSpace(result.SourceName))
|
||||
{
|
||||
sb.AppendLine($"SourceDocName: {result.Name}");
|
||||
sb.AppendLine($"SourceDocName: {result.SourceName}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(result.Link))
|
||||
if (!string.IsNullOrWhiteSpace(result.SourceLink))
|
||||
{
|
||||
sb.AppendLine($"SourceDocLink: {result.Link}");
|
||||
sb.AppendLine($"SourceDocLink: {result.SourceLink}");
|
||||
}
|
||||
sb.AppendLine($"Contents: {result.Value}");
|
||||
sb.AppendLine($"Contents: {result.Text}");
|
||||
sb.AppendLine("----");
|
||||
}
|
||||
sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt);
|
||||
@@ -286,17 +292,17 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the source document (optional).
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a link/URL to the source document (optional).
|
||||
/// </summary>
|
||||
public string? Link { get; set; }
|
||||
public string? SourceLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the textual content of the retrieved chunk.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw representation of the search result from the data source.
|
||||
|
||||
@@ -30,12 +30,12 @@ public sealed class TextSearchProviderOptions
|
||||
public string? FunctionToolDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the context prompt prefixed to automatically injected results.
|
||||
/// Gets or sets the context prompt prefixed to results.
|
||||
/// </summary>
|
||||
public string? ContextPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the instruction appended after automatically injected results to request citations.
|
||||
/// Gets or sets the instruction appended after results to request citations.
|
||||
/// </summary>
|
||||
public string? CitationsPrompt { get; set; }
|
||||
|
||||
@@ -59,13 +59,33 @@ public sealed class TextSearchProviderOptions
|
||||
/// </value>
|
||||
public int RecentMessageMemoryLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of <see cref="ChatRole"/> types to filter recent messages to
|
||||
/// when deciding which recent messages to include when constructing the search input.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Depending on your scenario, you may want to use only user messages, only assistant messages,
|
||||
/// or both. For example, if the assistant may often provide clarifying questions or if the conversation
|
||||
/// is expected to be particularly chatty, you may want to include assistant messages in the search context as well.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Be careful when including assistant messages though, as they may skew the search results towards
|
||||
/// information that has already been provided by the assistant, rather than focusing on the user's current needs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// When not specified, defaults to only <see cref="ChatRole.User"/>.
|
||||
/// </value>
|
||||
public List<ChatRole>? RecentMessageRolesIncluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Behavior choices for the provider.
|
||||
/// </summary>
|
||||
public enum TextSearchBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute search prior to each invocation and inject results as instructions.
|
||||
/// Execute search prior to each invocation and inject results as a message.
|
||||
/// </summary>
|
||||
BeforeAIInvoke,
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
@@ -31,6 +32,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+3
-3
@@ -31,10 +31,10 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_ReturnsAIAgentAsync()
|
||||
public void GetAIAgent_ReturnsAIAgent()
|
||||
{
|
||||
// Act
|
||||
var agent = await this._agentCard.GetAIAgentAsync();
|
||||
var agent = this._agentCard.GetAIAgent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -56,7 +56,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
});
|
||||
|
||||
var agent = await this._agentCard.GetAIAgentAsync(httpClient);
|
||||
var agent = this._agentCard.GetAIAgent(httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mem0.UnitTests;
|
||||
|
||||
@@ -17,13 +18,23 @@ namespace Microsoft.Agents.AI.Mem0.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<Mem0Provider>> _loggerMock;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
|
||||
private readonly RecordingHandler _handler = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b => b.AddProvider(new NullLoggerProvider()));
|
||||
private bool _disposed;
|
||||
|
||||
public Mem0ProviderTests()
|
||||
{
|
||||
this._loggerMock = new();
|
||||
this._loggerFactoryMock = new();
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(It.IsAny<string>()))
|
||||
.Returns(this._loggerMock.Object);
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(typeof(Mem0Provider).FullName!))
|
||||
.Returns(this._loggerMock.Object);
|
||||
|
||||
this._httpClient = new HttpClient(this._handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://localhost/")
|
||||
@@ -80,7 +91,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
ThreadId = "thread",
|
||||
UserId = "user"
|
||||
};
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactory);
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
|
||||
|
||||
// Act
|
||||
@@ -99,6 +110,24 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var contextMessage = Assert.Single(aiContext.Messages);
|
||||
Assert.Equal(ChatRole.User, contextMessage.Role);
|
||||
Assert.Contains("Name is Caoimhe", contextMessage.Text);
|
||||
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Retrieved 1 memories.")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Search Results\nInput:What is my name?\nOutput")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,6 +185,39 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
Assert.Empty(this._handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
|
||||
this._handler.EnqueueEmptyInternalServerError();
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "User text"),
|
||||
new(ChatRole.System, "System text"),
|
||||
new(ChatRole.Tool, "Tool text should be ignored")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "Assistant text")
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to send messages to Mem0 due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
|
||||
{
|
||||
@@ -267,6 +329,30 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new Mem0ProviderOptions { ApplicationId = "app" };
|
||||
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Tools);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to search Mem0 for memories due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0;
|
||||
|
||||
public void Dispose()
|
||||
@@ -275,7 +361,6 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
this._httpClient.Dispose();
|
||||
this._handler.Dispose();
|
||||
this._loggerFactory.Dispose();
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -309,18 +394,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
}
|
||||
|
||||
public void EnqueueEmptyOk() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class NullLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => new NullLogger();
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class NullLogger : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => false;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
||||
}
|
||||
public void EnqueueEmptyInternalServerError() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ public sealed class TextSearchProviderTests
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
string? capturedInput = null;
|
||||
@@ -116,7 +116,7 @@ public sealed class TextSearchProviderTests
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider Input:Sample user question?\nAdditional part\nContext Instructions")),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
@@ -150,6 +150,29 @@ public sealed class TextSearchProviderTests
|
||||
Assert.Equal(expectedDescription, tool.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Tools);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("Custom context prompt", "Custom citations prompt")]
|
||||
@@ -158,8 +181,8 @@ public sealed class TextSearchProviderTests
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
@@ -210,8 +233,8 @@ public sealed class TextSearchProviderTests
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
@@ -244,8 +267,8 @@ public sealed class TextSearchProviderTests
|
||||
var payload2 = new RawPayload { Id = "R2" };
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Value = "Content 1", RawRepresentation = payload1 },
|
||||
new() { Name = "Doc2", Value = "Content 2", RawRepresentation = payload2 }
|
||||
new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 },
|
||||
new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
@@ -335,7 +358,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3
|
||||
RecentMessageMemoryLimit = 3,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
@@ -374,7 +398,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 5
|
||||
RecentMessageMemoryLimit = 5,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
@@ -408,6 +433,46 @@ public sealed class TextSearchProviderTests
|
||||
Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4,
|
||||
RecentMessageRolesIncluded = new List<ChatRole> { ChatRole.Assistant } // Only retain assistant messages.
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, options);
|
||||
|
||||
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
|
||||
var initialMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "U1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "U2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages, null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "Question?") // Current request message always appended.
|
||||
});
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
@@ -438,7 +503,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3
|
||||
RecentMessageMemoryLimit = 3,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
|
||||
var messages = new[]
|
||||
@@ -467,7 +533,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4
|
||||
RecentMessageMemoryLimit = 4,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
|
||||
var messages = new[]
|
||||
@@ -507,7 +574,8 @@ public sealed class TextSearchProviderTests
|
||||
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 5
|
||||
RecentMessageMemoryLimit = 5,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
});
|
||||
var messages = new[]
|
||||
{
|
||||
@@ -574,6 +642,11 @@ public sealed class TextSearchProviderTests
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> FailingSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Search Failed");
|
||||
}
|
||||
|
||||
private sealed class RawPayload
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
+57
-6
@@ -225,6 +225,54 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
Assert.True(visitor.HasUnsupportedActions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CaseInsensitive.yaml", "end_when_match")]
|
||||
[InlineData("ClearAllVariables.yaml", "clear_all")]
|
||||
[InlineData("Condition.yaml", "setVariable_test")]
|
||||
[InlineData("ConditionElse.yaml", "setVariable_test")]
|
||||
[InlineData("EndConversation.yaml", "end_all")]
|
||||
[InlineData("EndDialog.yaml", "end_all")]
|
||||
[InlineData("EditTable.yaml", "edit_var")]
|
||||
[InlineData("EditTableV2.yaml", "edit_var")]
|
||||
[InlineData("Goto.yaml", "goto_end")]
|
||||
[InlineData("LoopBreak.yaml", "break_loop_now")]
|
||||
[InlineData("LoopContinue.yaml", "foreach_loop")]
|
||||
[InlineData("LoopEach.yaml", "foreach_loop")]
|
||||
[InlineData("MixedScopes.yaml", "activity_input")]
|
||||
[InlineData("ParseValue.yaml", "parse_var")]
|
||||
[InlineData("ParseValueList.yaml", "parse_var")]
|
||||
[InlineData("ResetVariable.yaml", "clear_var")]
|
||||
[InlineData("SendActivity.yaml", "activity_input")]
|
||||
[InlineData("SetVariable.yaml", "set_var")]
|
||||
[InlineData("SetTextVariable.yaml", "set_text")]
|
||||
public async Task CancelRunAsync(string workflowPath, string expectedExecutedId)
|
||||
{
|
||||
// Arrange
|
||||
const string WorkflowInput = "Test input message";
|
||||
Workflow workflow = this.CreateWorkflow(workflowPath, WorkflowInput);
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow: workflow, input: WorkflowInput);
|
||||
|
||||
// Act
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||
{
|
||||
this.WorkflowEvents.Add(workflowEvent);
|
||||
|
||||
if (workflowEvent is DeclarativeActionInvokedEvent actionInvokedEvent && actionInvokedEvent.ActionId == expectedExecutedId)
|
||||
{
|
||||
// Cancel run after the specified declarative action is invoked.
|
||||
await run.CancelRunAsync();
|
||||
}
|
||||
}
|
||||
RunStatus currentRunStatus = await run.GetStatusAsync();
|
||||
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected: RunStatus.Ended, actual: currentRunStatus);
|
||||
Assert.NotEmpty(this.WorkflowEventCounts);
|
||||
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionInvokedEvent>(), e => e.ActionId == expectedExecutedId);
|
||||
Assert.DoesNotContain(this.WorkflowEvents.OfType<DeclarativeActionCompletedEvent>(), e => e.ActionId == expectedExecutedId);
|
||||
}
|
||||
|
||||
private void AssertExecutionCount(int expectedCount)
|
||||
{
|
||||
Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokedEvent)]);
|
||||
@@ -256,12 +304,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
|
||||
private async Task RunWorkflowAsync<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
|
||||
{
|
||||
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
|
||||
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
|
||||
Workflow workflow = this.CreateWorkflow(workflowPath, workflowInput);
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput);
|
||||
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||
@@ -303,6 +346,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
|
||||
}
|
||||
|
||||
private Workflow CreateWorkflow<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
|
||||
{
|
||||
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
}
|
||||
|
||||
private static Mock<WorkflowAgentProvider> CreateMockProvider(string input)
|
||||
{
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
|
||||
|
||||
@@ -167,7 +167,7 @@ public class JsonSerializationTests
|
||||
builder.AddEdge(forwardString, stringToInt)
|
||||
.AddEdge(stringToInt, forwardInt)
|
||||
.AddEdge(forwardInt, intToString)
|
||||
.AddEdge(intToString, StreamingAggregators.Last<int>().AsExecutor("Aggregate"));
|
||||
.AddEdge(intToString, StreamingAggregators.Last<int>().BindAsExecutor("Aggregate"));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -38,34 +40,40 @@ public class RepresentationTests
|
||||
private static RequestPort TestRequestPort =>
|
||||
RequestPort.Create<FunctionCallContent, FunctionResultContent>("ExternalFunction");
|
||||
|
||||
private static async ValueTask RunExecutorishInfoMatchTestAsync(ExecutorIsh target)
|
||||
private static async ValueTask RunExecutorBindingInfoMatchTestAsync(ExecutorBinding binding)
|
||||
{
|
||||
ExecutorRegistration registration = target.Registration;
|
||||
ExecutorInfo info = registration.ToExecutorInfo();
|
||||
ExecutorInfo info = binding.ToExecutorInfo();
|
||||
|
||||
info.IsMatch(await registration.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
|
||||
info.IsMatch(await binding.CreateInstanceAsync(runId: string.Empty)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_Executorish_InfosAsync()
|
||||
public async Task Test_ExecutorBinding_InfosAsync()
|
||||
{
|
||||
int testsRun = 0;
|
||||
await RunExecutorishTestAsync(new TestExecutor());
|
||||
await RunExecutorishTestAsync(TestRequestPort);
|
||||
await RunExecutorishTestAsync(new TestAgent());
|
||||
await RunExecutorishTestAsync(Step1EntryPoint.WorkflowInstance.ConfigureSubWorkflow(nameof(Step1EntryPoint)));
|
||||
await RunExecutorBindingTestAsync(new TestExecutor());
|
||||
await RunExecutorBindingTestAsync(TestRequestPort);
|
||||
await RunExecutorBindingTestAsync(new TestAgent());
|
||||
await RunExecutorBindingTestAsync(Step1EntryPoint.WorkflowInstance.BindAsExecutor(nameof(Step1EntryPoint)));
|
||||
|
||||
Func<int, IWorkflowContext, CancellationToken, ValueTask> function = MessageHandlerAsync;
|
||||
await RunExecutorishTestAsync(function.AsExecutor("FunctionExecutor"));
|
||||
await RunExecutorBindingTestAsync(function.BindAsExecutor("FunctionExecutor"));
|
||||
|
||||
if (Enum.GetValues(typeof(ExecutorIsh.Type)).Length > testsRun + 1)
|
||||
Type bindingBaseType = typeof(ExecutorBinding);
|
||||
Assembly workflowAssembly = bindingBaseType.Assembly;
|
||||
int expectedTests = workflowAssembly.GetTypes()
|
||||
.Count(type => type != bindingBaseType
|
||||
&& bindingBaseType.IsAssignableFrom(type));
|
||||
expectedTests.Should().BePositive();
|
||||
|
||||
if (expectedTests > testsRun + 1)
|
||||
{
|
||||
Assert.Fail("Not all ExecutorIsh types were tested.");
|
||||
Assert.Fail("Not all ExecutorBinding types were tested.");
|
||||
}
|
||||
|
||||
async ValueTask RunExecutorishTestAsync(ExecutorIsh executorish)
|
||||
async ValueTask RunExecutorBindingTestAsync(ExecutorBinding binding)
|
||||
{
|
||||
await RunExecutorishInfoMatchTestAsync(executorish);
|
||||
await RunExecutorBindingInfoMatchTestAsync(binding);
|
||||
testsRun++;
|
||||
}
|
||||
|
||||
@@ -77,8 +85,8 @@ public class RepresentationTests
|
||||
[Fact]
|
||||
public async Task Test_SpecializedExecutor_InfosAsync()
|
||||
{
|
||||
await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
|
||||
await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
|
||||
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
|
||||
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
|
||||
}
|
||||
|
||||
private static string Source(int id) => $"Source/{id}";
|
||||
|
||||
+11
-9
@@ -11,21 +11,23 @@ internal static class Step7EntryPoint
|
||||
public static string EchoAgentId => Step6EntryPoint.EchoAgentId;
|
||||
public static string EchoPrefix => Step6EntryPoint.EchoPrefix;
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2)
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2, int numIterations = 2)
|
||||
{
|
||||
Workflow workflow = Step6EntryPoint.CreateWorkflow(maxSteps);
|
||||
|
||||
AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent");
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false))
|
||||
for (int i = 0; i < numIterations; i++)
|
||||
{
|
||||
string updateText = $"{update.AuthorName
|
||||
?? update.AgentId
|
||||
?? update.Role.ToString()
|
||||
?? ChatRole.Assistant.ToString()}: {update.Text}";
|
||||
writer.WriteLine(updateText);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(thread).ConfigureAwait(false))
|
||||
{
|
||||
string updateText = $"{update.AuthorName
|
||||
?? update.AgentId
|
||||
?? update.Role.ToString()
|
||||
?? ChatRole.Assistant.ToString()}: {update.Text}";
|
||||
writer.WriteLine(updateText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -29,13 +29,13 @@ internal static class Step8EntryPoint
|
||||
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List<string> textsToProcess)
|
||||
{
|
||||
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> processTextAsyncFunc = ProcessTextAsync;
|
||||
ExecutorIsh processText = processTextAsyncFunc.AsExecutor("TextProcessor", threadsafe: true);
|
||||
ExecutorBinding processText = processTextAsyncFunc.BindAsExecutor("TextProcessor", threadsafe: true);
|
||||
|
||||
Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
|
||||
|
||||
ExecutorIsh textProcessor = subWorkflow.ConfigureSubWorkflow("TextProcessor");
|
||||
ExecutorBinding textProcessor = subWorkflow.BindAsExecutor("TextProcessor");
|
||||
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id));
|
||||
var orchestrator = createOrchestrator.ConfigureFactory();
|
||||
var orchestrator = createOrchestrator.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(orchestrator)
|
||||
.AddEdge(orchestrator, textProcessor)
|
||||
|
||||
+27
-5
@@ -62,7 +62,7 @@ internal sealed record class RequestFinished(string Id, string RequestType, Reso
|
||||
|
||||
internal static class Step9EntryPoint
|
||||
{
|
||||
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, ExecutorIsh filter, string? id = null)
|
||||
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding filter, string? id = null)
|
||||
{
|
||||
id ??= typeof(TRequest).Name;
|
||||
|
||||
@@ -74,10 +74,10 @@ internal static class Step9EntryPoint
|
||||
.ForwardMessage<ExternalResponse>(filter, executors: [source], condition: message => message.DataIs<TResponse>());
|
||||
}
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string? id = null)
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string? id = null)
|
||||
=> builder.AddExternalRequest(source, out RequestPort<TRequest, TResponse> _, id);
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
|
||||
{
|
||||
id = id ?? $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
|
||||
|
||||
@@ -86,7 +86,7 @@ internal static class Step9EntryPoint
|
||||
return builder.AddExternalRequest(source, inputPort);
|
||||
}
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, RequestPort<TRequest, TResponse> inputPort)
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, RequestPort<TRequest, TResponse> inputPort)
|
||||
{
|
||||
return builder.ForwardMessage<TRequest>(source, inputPort)
|
||||
.ForwardMessage<ExternalRequest>(source, inputPort)
|
||||
@@ -110,7 +110,7 @@ internal static class Step9EntryPoint
|
||||
Coordinator coordinator = new();
|
||||
ResourceCache cache = new();
|
||||
QuotaPolicyEngine policyEngine = new();
|
||||
ExecutorIsh subworkflow = CreateSubWorkflow().ConfigureSubWorkflow("ResourceWorkflow");
|
||||
ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow");
|
||||
|
||||
return new WorkflowBuilder(coordinator)
|
||||
.AddChain(coordinator, allowRepetition: true, subworkflow, coordinator)
|
||||
@@ -522,4 +522,26 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCross
|
||||
return state + requests.Count;
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask RunWorkflowHandleEventsAsync<TInput>(Workflow workflow, TInput input) where TInput : notnull
|
||||
{
|
||||
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case ExecutorInvokedEvent invoked:
|
||||
Console.WriteLine($"Executor invoked: {invoked.ExecutorId}");
|
||||
break;
|
||||
case ExecutorCompletedEvent completed:
|
||||
Console.WriteLine($"Executor completed: {completed.ExecutorId}");
|
||||
break;
|
||||
|
||||
// Other event types can be handled here as needed
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,8 @@ public class SampleSmokeTest
|
||||
string[] lines = result.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
Assert.Collection(lines,
|
||||
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
|
||||
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line),
|
||||
line => Assert.Contains($"{HelloAgent.DefaultId}: {HelloAgent.Greeting}", line),
|
||||
line => Assert.Contains($"{Step7EntryPoint.EchoAgentId}: {Step7EntryPoint.EchoPrefix}{HelloAgent.Greeting}", line)
|
||||
);
|
||||
|
||||
@@ -30,9 +30,9 @@ public partial class WorkflowBuilderSmokeTests
|
||||
|
||||
workflow.StartExecutorId.Should().Be("start");
|
||||
|
||||
workflow.Registrations.Should().HaveCount(1);
|
||||
workflow.Registrations.Should().ContainKey("start");
|
||||
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
workflow.ExecutorBindings.Should().HaveCount(1);
|
||||
workflow.ExecutorBindings.Should().ContainKey("start");
|
||||
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -45,9 +45,9 @@ public partial class WorkflowBuilderSmokeTests
|
||||
|
||||
workflow.StartExecutorId.Should().Be("start");
|
||||
|
||||
workflow.Registrations.Should().HaveCount(1);
|
||||
workflow.Registrations.Should().ContainKey("start");
|
||||
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
workflow.ExecutorBindings.Should().HaveCount(1);
|
||||
workflow.ExecutorBindings.Should().ContainKey("start");
|
||||
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -77,9 +77,9 @@ public partial class WorkflowBuilderSmokeTests
|
||||
|
||||
workflow.StartExecutorId.Should().Be("start");
|
||||
|
||||
workflow.Registrations.Should().HaveCount(1);
|
||||
workflow.Registrations.Should().ContainKey("start");
|
||||
workflow.Registrations["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
workflow.ExecutorBindings.Should().HaveCount(1);
|
||||
workflow.ExecutorBindings.Should().ContainKey("start");
|
||||
workflow.ExecutorBindings["start"].ExecutorType.Should().Be<NoOpExecutor>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user