Merge branch 'main' into feature-azure-functions

This commit is contained in:
Dmytro Struk
2025-11-04 16:44:58 -08:00
Unverified
147 changed files with 9276 additions and 4903 deletions
+2
View File
@@ -60,6 +60,8 @@ jobs:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
+2
View File
@@ -65,9 +65,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" />
+10
View File
@@ -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" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
@@ -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/">
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251028.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251028.1</PackageVersion>
<GitTag>1.0.0-preview.251028.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
<GitTag>1.0.0-preview.251104.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -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
@@ -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))
@@ -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>
@@ -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."
};
}
@@ -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; }
}
@@ -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);
}
}
@@ -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; }
}
}
@@ -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; }
}
@@ -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>
@@ -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;
}
@@ -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."
});
}
@@ -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
@@ -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
```
@@ -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();
@@ -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();
}
}
}
}
}
@@ -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>
@@ -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);
}
}
}
}
@@ -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");
@@ -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
@@ -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);
}
}
@@ -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;
}
@@ -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" />
</ItemGroup>
</Project>
@@ -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;
@@ -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,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);
}
}
}
}
@@ -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)
@@ -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]
+25 -1
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b251104] - 2025-11-04
### Added
- Introducing the Anthropic Client ([#1819](https://github.com/microsoft/agent-framework/pull/1819))
### Changed
- [BREAKING] Consolidate workflow run APIs ([#1723](https://github.com/microsoft/agent-framework/pull/1723))
- [BREAKING] Remove request_type param from ctx.request_info() ([#1824](https://github.com/microsoft/agent-framework/pull/1824))
- [BREAKING] Cleanup of dependencies ([#1803](https://github.com/microsoft/agent-framework/pull/1803))
- [BREAKING] Replace `RequestInfoExecutor` with `request_info` API and `@response_handler` ([#1466](https://github.com/microsoft/agent-framework/pull/1466))
- Azure AI Search Support Update + Refactored Samples & Unit Tests ([#1683](https://github.com/microsoft/agent-framework/pull/1683))
- Lab: Updates to GAIA module ([#1763](https://github.com/microsoft/agent-framework/pull/1763))
### Fixed
- Azure AI `top_p` and `temperature` parameters fix ([#1839](https://github.com/microsoft/agent-framework/pull/1839))
- Ensure agent thread is part of checkpoint ([#1756](https://github.com/microsoft/agent-framework/pull/1756))
- Fix middleware and cleanup confusing function ([#1865](https://github.com/microsoft/agent-framework/pull/1865))
- Fix type compatibility check ([#1753](https://github.com/microsoft/agent-framework/pull/1753))
- Fix mcp tool cloning for handoff pattern ([#1883](https://github.com/microsoft/agent-framework/pull/1883))
## [1.0.0b251028] - 2025-10-28
### Added
@@ -124,7 +147,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251104...HEAD
[1.0.0b251104]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251028...python-1.0.0b251104
[1.0.0b251028]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251016...python-1.0.0b251028
[1.0.0b251016]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251007...python-1.0.0b251016
[1.0.0b251007]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251001...python-1.0.0b251007
@@ -127,7 +127,21 @@ class A2AAgent(BaseAgent):
)
factory = ClientFactory(config)
interceptors = [auth_interceptor] if auth_interceptor is not None else None
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
# Attempt transport negotiation with the provided agent card
try:
self.client = factory.create(agent_card, interceptors=interceptors) # type: ignore
except Exception as transport_error:
# Transport negotiation failed - fall back to minimal agent card with JSONRPC
fallback_card = minimal_agent_card(agent_card.url, [TransportProtocol.jsonrpc])
try:
self.client = factory.create(fallback_card, interceptors=interceptors) # type: ignore
except Exception as fallback_error:
raise RuntimeError(
f"A2A transport negotiation failed. "
f"Primary error: {transport_error}. "
f"Fallback error: {fallback_error}"
) from transport_error
async def __aenter__(self) -> "A2AAgent":
"""Async context manager entry."""
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251028"
version = "1.0.0b251104"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+41 -2
View File
@@ -2,10 +2,22 @@
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
from a2a.types import Artifact, DataPart, FilePart, FileWithUri, Message, Part, Task, TaskState, TaskStatus, TextPart
from a2a.types import (
AgentCard,
Artifact,
DataPart,
FilePart,
FileWithUri,
Message,
Part,
Task,
TaskState,
TaskStatus,
TextPart,
)
from a2a.types import Role as A2ARole
from agent_framework import (
AgentRunResponse,
@@ -515,3 +527,30 @@ def test_auth_interceptor_parameter() -> None:
# Verify the agent was created successfully
assert agent.name == "test-agent"
assert agent.client is not None
def test_transport_negotiation_both_fail() -> None:
"""Test that RuntimeError is raised when both primary and fallback transport negotiation fail."""
# Create a mock agent card
mock_agent_card = MagicMock(spec=AgentCard)
mock_agent_card.url = "http://test-agent.example.com"
# Mock the factory to simulate both primary and fallback failures
mock_factory = MagicMock()
# Both calls to factory.create() fail
primary_error = Exception("no compatible transports found")
fallback_error = Exception("fallback also failed")
mock_factory.create.side_effect = [primary_error, fallback_error]
with (
patch("agent_framework_a2a._agent.ClientFactory", return_value=mock_factory),
patch("agent_framework_a2a._agent.minimal_agent_card"),
patch("agent_framework_a2a._agent.httpx.AsyncClient"),
raises(RuntimeError, match="A2A transport negotiation failed"),
):
# Attempt to create A2AAgent - should raise RuntimeError
A2AAgent(
name="test-agent",
agent_card=mock_agent_card,
)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+18
View File
@@ -0,0 +1,18 @@
# Get Started with Microsoft Agent Framework Anthropic
Please install this package via pip:
```bash
pip install agent-framework-anthropic --pre
```
## Anthropic Integration
The Anthropic integration enables communication with the Anthropic API, allowing your Agent Framework applications to leverage Anthropic's capabilities.
### Basic Usage Example
See the [Anthropic agent examples](https://github.com/microsoft/agent-framework/tree/main/python/samples/getting_started/agents/anthropic/) which demonstrate:
- Connecting to a Anthropic endpoint with an agent
- Streaming and non-streaming responses
@@ -0,0 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_client import AnthropicClient
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AnthropicClient",
"__version__",
]
@@ -0,0 +1,658 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, Final, TypeVar
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AIFunction,
Annotations,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
Contents,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedMCPTool,
HostedWebSearchTool,
Role,
TextContent,
TextReasoningContent,
TextSpanRegion,
ToolProtocol,
UsageContent,
UsageDetails,
get_logger,
prepare_function_call_results,
use_chat_middleware,
use_function_invocation,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from anthropic import AsyncAnthropic
from anthropic.types.beta import (
BetaContentBlock,
BetaMessage,
BetaMessageDeltaUsage,
BetaRawContentBlockDelta,
BetaRawMessageStreamEvent,
BetaTextBlock,
BetaUsage,
)
from pydantic import SecretStr, ValidationError
logger = get_logger("agent_framework.anthropic")
ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024
BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"]
ROLE_MAP: dict[Role, str] = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
Role.SYSTEM: "user",
Role.TOOL: "user",
}
FINISH_REASON_MAP: dict[str, FinishReason] = {
"stop_sequence": FinishReason.STOP,
"max_tokens": FinishReason.LENGTH,
"tool_use": FinishReason.TOOL_CALLS,
"end_turn": FinishReason.STOP,
"refusal": FinishReason.CONTENT_FILTER,
"pause_turn": FinishReason.STOP,
}
class AnthropicSettings(AFBaseSettings):
"""Anthropic Project settings.
The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'.
If the environment variables are not found, the settings can be loaded from a .env file
with the encoding 'utf-8'. If the settings are not found in the .env file, the settings
are ignored; however, validation will fail alerting that the settings are missing.
Keyword Args:
api_key: The Anthropic API key.
chat_model_id: The Anthropic chat model ID.
env_file_path: If provided, the .env settings are read from this file path location.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework.anthropic import AnthropicSettings
# Using environment variables
# Set ANTHROPIC_API_KEY=your_anthropic_api_key
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
# Or passing parameters directly
settings = AnthropicSettings(chat_model_id="claude-sonnet-4-5-20250929")
# Or loading from a .env file
settings = AnthropicSettings(env_file_path="path/to/.env")
"""
env_prefix: ClassVar[str] = "ANTHROPIC_"
api_key: SecretStr | None = None
chat_model_id: str | None = None
TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient")
@use_function_invocation
@use_observability
@use_chat_middleware
class AnthropicClient(BaseChatClient):
"""Anthropic Chat client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
def __init__(
self,
*,
api_key: str | None = None,
model_id: str | None = None,
anthropic_client: AsyncAnthropic | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize an Anthropic Agent client.
Keyword Args:
api_key: The Anthropic API key to use for authentication.
model_id: The ID of the model to use.
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
This can be used to further configure the client before passing it in.
For instance if you need to set a different base_url for testing or private deployments.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
kwargs: Additional keyword arguments passed to the parent class.
Examples:
.. code-block:: python
from agent_framework.anthropic import AnthropicClient
from azure.identity.aio import DefaultAzureCredential
# Using environment variables
# Set ANTHROPIC_API_KEY=your_anthropic_api_key
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
# Or passing parameters directly
client = AnthropicClient(
model_id="claude-sonnet-4-5-20250929",
api_key="your_anthropic_api_key",
)
# Or loading from a .env file
client = AnthropicClient(env_file_path="path/to/.env")
# Or passing in an existing client
from anthropic import AsyncAnthropic
anthropic_client = AsyncAnthropic(
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
)
client = AnthropicClient(
model_id="claude-sonnet-4-5-20250929",
anthropic_client=anthropic_client,
)
"""
try:
anthropic_settings = AnthropicSettings(
api_key=api_key, # type: ignore[arg-type]
chat_model_id=model_id,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
except ValidationError as ex:
raise ServiceInitializationError("Failed to create Anthropic settings.", ex) from ex
if anthropic_client is None:
if not anthropic_settings.api_key:
raise ServiceInitializationError(
"Anthropic API key is required. Set via 'api_key' parameter "
"or 'ANTHROPIC_API_KEY' environment variable."
)
anthropic_client = AsyncAnthropic(
api_key=anthropic_settings.api_key.get_secret_value(),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
)
# Initialize parent
super().__init__(**kwargs)
# Initialize instance variables
self.anthropic_client = anthropic_client
self.model_id = anthropic_settings.chat_model_id
# streaming requires tracking the last function call ID and name
self._last_call_id_name: tuple[str, str] | None = None
# region Get response methods
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
# Extract necessary state from messages and options
run_options = self._create_run_options(messages, chat_options, **kwargs)
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
return self._process_message(message)
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# Extract necessary state from messages and options
run_options = self._create_run_options(messages, chat_options, **kwargs)
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
parsed_chunk = self._process_stream_event(chunk)
if parsed_chunk:
yield parsed_chunk
# region Create Run Options and Helpers
def _create_run_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
**kwargs: Any,
) -> dict[str, Any]:
"""Create run options for the Anthropic client based on messages and chat options.
Args:
messages: The list of chat messages.
chat_options: The chat options.
kwargs: Additional keyword arguments.
Returns:
A dictionary of run options for the Anthropic client.
"""
run_options: dict[str, Any] = {
"model": chat_options.model_id or self.model_id,
"messages": self._convert_messages_to_anthropic_format(messages),
"max_tokens": chat_options.max_tokens or ANTHROPIC_DEFAULT_MAX_TOKENS,
"extra_headers": {"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
"betas": BETA_FLAGS,
}
# Add any additional options from chat_options or kwargs
if chat_options.temperature is not None:
run_options["temperature"] = chat_options.temperature
if chat_options.top_p is not None:
run_options["top_p"] = chat_options.top_p
if chat_options.stop is not None:
run_options["stop_sequences"] = chat_options.stop
if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM:
# first system message is passed as instructions
run_options["system"] = messages[0].text
if chat_options.tool_choice is not None:
match (
chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode
):
case "auto":
run_options["tool_choice"] = {"type": "auto"}
if chat_options.allow_multiple_tool_calls is not None:
run_options["tool_choice"][ # type:ignore[reportArgumentType]
"disable_parallel_tool_use"
] = not chat_options.allow_multiple_tool_calls
case "required":
if chat_options.tool_choice.required_function_name:
run_options["tool_choice"] = {
"type": "tool",
"name": chat_options.tool_choice.required_function_name,
}
if chat_options.allow_multiple_tool_calls is not None:
run_options["tool_choice"][ # type:ignore[reportArgumentType]
"disable_parallel_tool_use"
] = not chat_options.allow_multiple_tool_calls
else:
run_options["tool_choice"] = {"type": "any"}
if chat_options.allow_multiple_tool_calls is not None:
run_options["tool_choice"][ # type:ignore[reportArgumentType]
"disable_parallel_tool_use"
] = not chat_options.allow_multiple_tool_calls
case "none":
run_options["tool_choice"] = {"type": "none"}
case _:
logger.debug(f"Ignoring unsupported tool choice mode: {chat_options.tool_choice.mode} for now")
if tools_and_mcp := self._convert_tools_to_anthropic_format(chat_options.tools):
run_options.update(tools_and_mcp)
if chat_options.additional_properties:
run_options.update(chat_options.additional_properties)
run_options.update(kwargs)
return run_options
def _convert_messages_to_anthropic_format(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
"""Convert a list of ChatMessages to the format expected by the Anthropic client.
This skips the first message if it is a system message,
as Anthropic expects system instructions as a separate parameter.
"""
# first system message is passed as instructions
if messages and isinstance(messages[0], ChatMessage) and messages[0].role == Role.SYSTEM:
return [self._convert_message_to_anthropic_format(msg) for msg in messages[1:]]
return [self._convert_message_to_anthropic_format(msg) for msg in messages]
def _convert_message_to_anthropic_format(self, message: ChatMessage) -> dict[str, Any]:
"""Convert a ChatMessage to the format expected by the Anthropic client.
Args:
message: The ChatMessage to convert.
Returns:
A dictionary representing the message in Anthropic format.
"""
a_content: list[dict[str, Any]] = []
for content in message.contents:
match content.type:
case "text":
a_content.append({"type": "text", "text": content.text})
case "data":
if content.has_top_level_media_type("image"):
a_content.append({
"type": "image",
"source": {"data": content.uri, "media_type": content.media_type},
})
case "uri":
if content.has_top_level_media_type("image"):
a_content.append({"type": "image", "source": {"type": "url", "url": content.uri}})
case "function_call":
a_content.append({
"type": "tool_use",
"id": content.call_id,
"name": content.name,
"input": content.parse_arguments(),
})
case "function_result":
a_content.append({
"type": "tool_result",
"tool_use_id": content.call_id,
"content": prepare_function_call_results(content.result),
"is_error": content.exception is not None,
})
case "text_reasoning":
a_content.append({"type": "thinking", "thinking": content.text})
case _:
logger.debug(f"Ignoring unsupported content type: {content.type} for now")
return {
"role": ROLE_MAP.get(message.role, "user"),
"content": a_content,
}
def _convert_tools_to_anthropic_format(
self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None
) -> dict[str, Any] | None:
if not tools:
return None
tool_list: list[MutableMapping[str, Any]] = []
mcp_server_list: list[MutableMapping[str, Any]] = []
for tool in tools:
match tool:
case MutableMapping():
tool_list.append(tool)
case AIFunction():
tool_list.append({
"type": "custom",
"name": tool.name,
"description": tool.description,
"input_schema": tool.parameters(),
})
case HostedWebSearchTool():
search_tool: dict[str, Any] = {
"type": "web_search_20250305",
"name": "web_search",
}
if tool.additional_properties:
search_tool.update(tool.additional_properties)
tool_list.append(search_tool)
case HostedCodeInterpreterTool():
code_tool: dict[str, Any] = {
"type": "code_execution_20250825",
"name": "code_interpreter",
}
tool_list.append(code_tool)
case HostedMCPTool():
server_def: dict[str, Any] = {
"type": "url",
"name": tool.name,
"url": str(tool.url),
}
if tool.allowed_tools:
server_def["tool_configuration"] = {"allowed_tools": list(tool.allowed_tools)}
if tool.headers and (auth := tool.headers.get("authorization")):
server_def["authorization_token"] = auth
mcp_server_list.append(server_def)
case _:
logger.debug(f"Ignoring unsupported tool type: {type(tool)} for now")
all_tools: dict[str, list[MutableMapping[str, Any]]] = {}
if tool_list:
all_tools["tools"] = tool_list
if mcp_server_list:
all_tools["mcp_servers"] = mcp_server_list
return all_tools
# region Response Processing Methods
def _process_message(self, message: BetaMessage) -> ChatResponse:
"""Process the response from the Anthropic client.
Args:
message: The message returned by the Anthropic client.
Returns:
A ChatResponse object containing the processed response.
"""
return ChatResponse(
response_id=message.id,
messages=[
ChatMessage(
role=Role.ASSISTANT,
contents=self._parse_message_contents(message.content),
raw_representation=message,
)
],
usage_details=self._parse_message_usage(message.usage),
model_id=message.model,
finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None,
raw_response=message,
)
def _process_stream_event(self, event: BetaRawMessageStreamEvent) -> ChatResponseUpdate | None:
"""Process a streaming event from the Anthropic client.
Args:
event: The streaming event returned by the Anthropic client.
Returns:
A ChatResponseUpdate object containing the processed update.
"""
match event.type:
case "message_start":
usage_details: list[UsageContent] = []
if event.message.usage and (details := self._parse_message_usage(event.message.usage)):
usage_details.append(UsageContent(details=details))
return ChatResponseUpdate(
response_id=event.message.id,
contents=[*self._parse_message_contents(event.message.content), *usage_details],
model_id=event.message.model,
finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason)
if event.message.stop_reason
else None,
raw_response=event,
)
case "message_delta":
usage = self._parse_message_usage(event.usage)
return ChatResponseUpdate(
contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [],
raw_response=event,
)
case "message_stop":
logger.debug("Received message_stop event; no content to process.")
case "content_block_start":
contents = self._parse_message_contents([event.content_block])
return ChatResponseUpdate(
contents=contents,
raw_response=event,
)
case "content_block_delta":
contents = self._parse_message_contents([event.delta])
return ChatResponseUpdate(
contents=contents,
raw_response=event,
)
case "content_block_stop":
logger.debug("Received content_block_stop event; no content to process.")
case _:
logger.debug(f"Ignoring unsupported event type: {event.type}")
return None
def _parse_message_usage(self, usage: BetaUsage | BetaMessageDeltaUsage | None) -> UsageDetails | None:
"""Parse usage details from the Anthropic message usage."""
if not usage:
return None
usage_details = UsageDetails(output_token_count=usage.output_tokens)
if usage.input_tokens is not None:
usage_details.input_token_count = usage.input_tokens
if usage.cache_creation_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_creation_input_tokens"] = usage.cache_creation_input_tokens
if usage.cache_read_input_tokens is not None:
usage_details.additional_counts["anthropic.cache_read_input_tokens"] = usage.cache_read_input_tokens
return usage_details
def _parse_message_contents(
self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock]
) -> list[Contents]:
"""Parse contents from the Anthropic message."""
contents: list[Contents] = []
for content_block in content:
match content_block.type:
case "text" | "text_delta":
contents.append(
TextContent(
text=content_block.text,
raw_representation=content_block,
annotations=self._parse_citations(content_block),
)
)
case "tool_use":
self._last_call_id_name = (content_block.id, content_block.name)
contents.append(
FunctionCallContent(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
raw_representation=content_block,
)
)
case "mcp_tool_use" | "server_tool_use":
self._last_call_id_name = (content_block.id, content_block.name)
contents.append(
FunctionCallContent(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
raw_representation=content_block,
)
)
case "mcp_tool_result":
call_id, name = self._last_call_id_name or (None, None)
contents.append(
FunctionResultContent(
call_id=content_block.tool_use_id,
name=name if name and call_id == content_block.tool_use_id else "mcp_tool",
result=self._parse_message_contents(content_block.content)
if isinstance(content_block.content, list)
else content_block.content,
raw_representation=content_block,
)
)
case "web_search_tool_result" | "web_fetch_tool_result":
call_id, name = self._last_call_id_name or (None, None)
contents.append(
FunctionResultContent(
call_id=content_block.tool_use_id,
name=name if name and call_id == content_block.tool_use_id else "web_tool",
result=content_block.content,
raw_representation=content_block,
)
)
case (
"code_execution_tool_result"
| "bash_code_execution_tool_result"
| "text_editor_code_execution_tool_result"
):
call_id, name = self._last_call_id_name or (None, None)
contents.append(
FunctionResultContent(
call_id=content_block.tool_use_id,
name=name if name and call_id == content_block.tool_use_id else "code_execution_tool",
result=content_block.content,
raw_representation=content_block,
)
)
case "input_json_delta":
call_id, name = self._last_call_id_name if self._last_call_id_name else ("", "")
contents.append(
FunctionCallContent(
call_id=call_id,
name=name,
arguments=content_block.partial_json,
raw_representation=content_block,
)
)
case "thinking" | "thinking_delta":
contents.append(TextReasoningContent(text=content_block.thinking, raw_representation=content_block))
case _:
logger.debug(f"Ignoring unsupported content type: {content_block.type} for now")
return contents
def _parse_citations(
self, content_block: BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock
) -> list[Annotations] | None:
content_citations = getattr(content_block, "citations", None)
if not content_citations:
return None
annotations: list[Annotations] = []
for citation in content_citations:
cit = CitationAnnotation(raw_representation=citation)
match citation.type:
case "char_location":
cit.title = citation.title
cit.snippet = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_char_index, end_index=citation.end_char_index)
)
case "page_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(
start_index=citation.start_page_number,
end_index=citation.end_page_number,
)
)
case "content_block_location":
cit.title = citation.document_title
cit.snippet = citation.cited_text
if citation.file_id:
cit.file_id = citation.file_id
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
)
case "web_search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.url
case "search_result_location":
cit.title = citation.title
cit.snippet = citation.cited_text
cit.url = citation.source
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
)
case _:
logger.debug(f"Unknown citation type encountered: {citation.type}")
annotations.append(cit)
return annotations or None
def service_url(self) -> str:
"""Get the service URL for the chat client.
Returns:
The service URL for the chat client, or None if not set.
"""
return str(self.anthropic_client.base_url)
+88
View File
@@ -0,0 +1,88 @@
[project]
name = "agent-framework-anthropic"
description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251104"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"anthropic>=0.70.0,<1",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_anthropic"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,56 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from pytest import fixture
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
@fixture
def anthropic_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for AnthropicSettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"ANTHROPIC_API_KEY": "test-api-key-12345",
"ANTHROPIC_CHAT_MODEL_ID": "claude-3-5-sonnet-20241022",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key in exclude_list:
monkeypatch.delenv(key, raising=False) # type: ignore
continue
monkeypatch.setenv(key, value) # type: ignore
return env_vars
@fixture
def mock_anthropic_client() -> MagicMock:
"""Fixture that provides a mock AsyncAnthropic client."""
mock_client = MagicMock()
mock_client.base_url = "https://api.anthropic.com"
# Mock beta.messages property
mock_client.beta = MagicMock()
mock_client.beta.messages = MagicMock()
mock_client.beta.messages.create = AsyncMock()
return mock_client
@@ -0,0 +1,777 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from typing import Annotated
from unittest.mock import MagicMock, patch
import pytest
from agent_framework import (
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponseUpdate,
FinishReason,
FunctionCallContent,
FunctionResultContent,
HostedCodeInterpreterTool,
HostedMCPTool,
HostedWebSearchTool,
Role,
TextContent,
TextReasoningContent,
ai_function,
)
from agent_framework.exceptions import ServiceInitializationError
from anthropic.types.beta import (
BetaMessage,
BetaTextBlock,
BetaToolUseBlock,
BetaUsage,
)
from pydantic import Field, ValidationError
from agent_framework_anthropic import AnthropicClient
from agent_framework_anthropic._chat_client import AnthropicSettings
skip_if_anthropic_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("ANTHROPIC_API_KEY", "") in ("", "test-api-key-12345"),
reason="No real ANTHROPIC_API_KEY provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
def create_test_anthropic_client(
mock_anthropic_client: MagicMock,
model_id: str | None = None,
anthropic_settings: AnthropicSettings | None = None,
) -> AnthropicClient:
"""Helper function to create AnthropicClient instances for testing, bypassing normal validation."""
if anthropic_settings is None:
anthropic_settings = AnthropicSettings(api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022")
# Create client instance directly
client = object.__new__(AnthropicClient)
# Set attributes directly
client.anthropic_client = mock_anthropic_client
client.model_id = model_id or anthropic_settings.chat_model_id
client._last_call_id_name = None
client.additional_properties = {}
client.middleware = None
return client
# Settings Tests
def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None:
"""Test AnthropicSettings initialization."""
settings = AnthropicSettings()
assert settings.api_key is not None
assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"]
assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
def test_anthropic_settings_init_with_explicit_values() -> None:
"""Test AnthropicSettings initialization with explicit values."""
settings = AnthropicSettings(
api_key="custom-api-key",
chat_model_id="claude-3-opus-20240229",
)
assert settings.api_key is not None
assert settings.api_key.get_secret_value() == "custom-api-key"
assert settings.chat_model_id == "claude-3-opus-20240229"
@pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True)
def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None:
"""Test AnthropicSettings when API key is missing."""
settings = AnthropicSettings()
assert settings.api_key is None
assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
# Client Initialization Tests
def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> None:
"""Test AnthropicClient initialization with existing anthropic_client."""
chat_client = create_test_anthropic_client(mock_anthropic_client, model_id="claude-3-5-sonnet-20241022")
assert chat_client.anthropic_client is mock_anthropic_client
assert chat_client.model_id == "claude-3-5-sonnet-20241022"
assert isinstance(chat_client, ChatClientProtocol)
def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[str, str]) -> None:
"""Test AnthropicClient initialization with auto-created anthropic_client."""
client = AnthropicClient(
api_key=anthropic_unit_test_env["ANTHROPIC_API_KEY"],
model_id=anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"],
)
assert client.anthropic_client is not None
assert client.model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"]
def test_anthropic_client_init_missing_api_key() -> None:
"""Test AnthropicClient initialization when API key is missing."""
with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings:
mock_settings.return_value.api_key = None
mock_settings.return_value.chat_model_id = "claude-3-5-sonnet-20241022"
with pytest.raises(ServiceInitializationError, match="Anthropic API key is required"):
AnthropicClient()
def test_anthropic_client_init_validation_error() -> None:
"""Test that ValidationError in AnthropicSettings is properly handled."""
with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings:
mock_settings.side_effect = ValidationError.from_exception_data("test", [])
with pytest.raises(ServiceInitializationError, match="Failed to create Anthropic settings"):
AnthropicClient()
def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None:
"""Test service_url method."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
assert chat_client.service_url() == "https://api.anthropic.com"
# Message Conversion Tests
def test_convert_message_to_anthropic_format_text(mock_anthropic_client: MagicMock) -> None:
"""Test converting text message to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(role=Role.USER, text="Hello, world!")
result = chat_client._convert_message_to_anthropic_format(message)
assert result["role"] == "user"
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert result["content"][0]["text"] == "Hello, world!"
def test_convert_message_to_anthropic_format_function_call(mock_anthropic_client: MagicMock) -> None:
"""Test converting function call message to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
call_id="call_123",
name="get_weather",
arguments={"location": "San Francisco"},
)
],
)
result = chat_client._convert_message_to_anthropic_format(message)
assert result["role"] == "assistant"
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "tool_use"
assert result["content"][0]["id"] == "call_123"
assert result["content"][0]["name"] == "get_weather"
assert result["content"][0]["input"] == {"location": "San Francisco"}
def test_convert_message_to_anthropic_format_function_result(mock_anthropic_client: MagicMock) -> None:
"""Test converting function result message to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id="call_123",
name="get_weather",
result="Sunny, 72°F",
)
],
)
result = chat_client._convert_message_to_anthropic_format(message)
assert result["role"] == "user"
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "tool_result"
assert result["content"][0]["tool_use_id"] == "call_123"
# The degree symbol might be escaped differently depending on JSON encoder
assert "Sunny" in result["content"][0]["content"]
assert "72" in result["content"][0]["content"]
assert result["content"][0]["is_error"] is False
def test_convert_message_to_anthropic_format_text_reasoning(mock_anthropic_client: MagicMock) -> None:
"""Test converting text reasoning message to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
message = ChatMessage(
role=Role.ASSISTANT,
contents=[TextReasoningContent(text="Let me think about this...")],
)
result = chat_client._convert_message_to_anthropic_format(message)
assert result["role"] == "assistant"
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "thinking"
assert result["content"][0]["thinking"] == "Let me think about this..."
def test_convert_messages_to_anthropic_format_with_system(mock_anthropic_client: MagicMock) -> None:
"""Test converting messages list with system message."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [
ChatMessage(role=Role.SYSTEM, text="You are a helpful assistant."),
ChatMessage(role=Role.USER, text="Hello!"),
]
result = chat_client._convert_messages_to_anthropic_format(messages)
# System message should be skipped
assert len(result) == 1
assert result[0]["role"] == "user"
assert result[0]["content"][0]["text"] == "Hello!"
def test_convert_messages_to_anthropic_format_without_system(mock_anthropic_client: MagicMock) -> None:
"""Test converting messages list without system message."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [
ChatMessage(role=Role.USER, text="Hello!"),
ChatMessage(role=Role.ASSISTANT, text="Hi there!"),
]
result = chat_client._convert_messages_to_anthropic_format(messages)
assert len(result) == 2
assert result[0]["role"] == "user"
assert result[1]["role"] == "assistant"
# Tool Conversion Tests
def test_convert_tools_to_anthropic_format_ai_function(mock_anthropic_client: MagicMock) -> None:
"""Test converting AIFunction to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
@ai_function
def get_weather(location: Annotated[str, Field(description="Location to get weather for")]) -> str:
"""Get weather for a location."""
return f"Weather for {location}"
tools = [get_weather]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["type"] == "custom"
assert result["tools"][0]["name"] == "get_weather"
assert "Get weather for a location" in result["tools"][0]["description"]
def test_convert_tools_to_anthropic_format_web_search(mock_anthropic_client: MagicMock) -> None:
"""Test converting HostedWebSearchTool to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
tools = [HostedWebSearchTool()]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["type"] == "web_search_20250305"
assert result["tools"][0]["name"] == "web_search"
def test_convert_tools_to_anthropic_format_code_interpreter(mock_anthropic_client: MagicMock) -> None:
"""Test converting HostedCodeInterpreterTool to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
tools = [HostedCodeInterpreterTool()]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["type"] == "code_execution_20250825"
assert result["tools"][0]["name"] == "code_interpreter"
def test_convert_tools_to_anthropic_format_mcp_tool(mock_anthropic_client: MagicMock) -> None:
"""Test converting HostedMCPTool to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
tools = [HostedMCPTool(name="test-mcp", url="https://example.com/mcp")]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "mcp_servers" in result
assert len(result["mcp_servers"]) == 1
assert result["mcp_servers"][0]["type"] == "url"
assert result["mcp_servers"][0]["name"] == "test-mcp"
assert result["mcp_servers"][0]["url"] == "https://example.com/mcp"
def test_convert_tools_to_anthropic_format_mcp_with_auth(mock_anthropic_client: MagicMock) -> None:
"""Test converting HostedMCPTool with authorization headers."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
tools = [
HostedMCPTool(
name="test-mcp",
url="https://example.com/mcp",
headers={"authorization": "Bearer token123"},
)
]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "mcp_servers" in result
# The authorization header is converted to authorization_token
assert "authorization_token" in result["mcp_servers"][0]
assert result["mcp_servers"][0]["authorization_token"] == "Bearer token123"
def test_convert_tools_to_anthropic_format_dict_tool(mock_anthropic_client: MagicMock) -> None:
"""Test converting dict tool to Anthropic format."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
tools = [{"type": "custom", "name": "custom_tool", "description": "A custom tool"}]
result = chat_client._convert_tools_to_anthropic_format(tools)
assert result is not None
assert "tools" in result
assert len(result["tools"]) == 1
assert result["tools"][0]["name"] == "custom_tool"
def test_convert_tools_to_anthropic_format_none(mock_anthropic_client: MagicMock) -> None:
"""Test converting None tools."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
result = chat_client._convert_tools_to_anthropic_format(None)
assert result is None
# Run Options Tests
async def test_create_run_options_basic(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with basic ChatOptions."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["model"] == chat_client.model_id
assert run_options["max_tokens"] == 100
assert run_options["temperature"] == 0.7
assert "messages" in run_options
async def test_create_run_options_with_system_message(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with system message."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [
ChatMessage(role=Role.SYSTEM, text="You are helpful."),
ChatMessage(role=Role.USER, text="Hello"),
]
chat_options = ChatOptions()
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["system"] == "You are helpful."
assert len(run_options["messages"]) == 1 # System message not in messages list
async def test_create_run_options_with_tool_choice_auto(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with auto tool choice."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(tool_choice="auto")
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["tool_choice"]["type"] == "auto"
async def test_create_run_options_with_tool_choice_required(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with required tool choice."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
# For required with specific function, need to pass as dict
chat_options = ChatOptions(tool_choice={"mode": "required", "required_function_name": "get_weather"})
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["tool_choice"]["type"] == "tool"
assert run_options["tool_choice"]["name"] == "get_weather"
async def test_create_run_options_with_tool_choice_none(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with none tool choice."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(tool_choice="none")
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["tool_choice"]["type"] == "none"
async def test_create_run_options_with_tools(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with tools."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
@ai_function
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"Weather for {location}"
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(tools=[get_weather])
run_options = chat_client._create_run_options(messages, chat_options)
assert "tools" in run_options
assert len(run_options["tools"]) == 1
async def test_create_run_options_with_stop_sequences(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with stop sequences."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(stop=["STOP", "END"])
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["stop_sequences"] == ["STOP", "END"]
async def test_create_run_options_with_top_p(mock_anthropic_client: MagicMock) -> None:
"""Test _create_run_options with top_p."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(top_p=0.9)
run_options = chat_client._create_run_options(messages, chat_options)
assert run_options["top_p"] == 0.9
# Response Processing Tests
def test_process_message_basic(mock_anthropic_client: MagicMock) -> None:
"""Test _process_message with basic text response."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
mock_message = MagicMock(spec=BetaMessage)
mock_message.id = "msg_123"
mock_message.model = "claude-3-5-sonnet-20241022"
mock_message.content = [BetaTextBlock(type="text", text="Hello there!")]
mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5)
mock_message.stop_reason = "end_turn"
response = chat_client._process_message(mock_message)
assert response.response_id == "msg_123"
assert response.model_id == "claude-3-5-sonnet-20241022"
assert len(response.messages) == 1
assert response.messages[0].role == Role.ASSISTANT
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].text == "Hello there!"
assert response.finish_reason == FinishReason.STOP
assert response.usage_details is not None
assert response.usage_details.input_token_count == 10
assert response.usage_details.output_token_count == 5
def test_process_message_with_tool_use(mock_anthropic_client: MagicMock) -> None:
"""Test _process_message with tool use."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
mock_message = MagicMock(spec=BetaMessage)
mock_message.id = "msg_123"
mock_message.model = "claude-3-5-sonnet-20241022"
mock_message.content = [
BetaToolUseBlock(
type="tool_use",
id="call_123",
name="get_weather",
input={"location": "San Francisco"},
)
]
mock_message.usage = BetaUsage(input_tokens=10, output_tokens=5)
mock_message.stop_reason = "tool_use"
response = chat_client._process_message(mock_message)
assert len(response.messages[0].contents) == 1
assert isinstance(response.messages[0].contents[0], FunctionCallContent)
assert response.messages[0].contents[0].call_id == "call_123"
assert response.messages[0].contents[0].name == "get_weather"
assert response.finish_reason == FinishReason.TOOL_CALLS
def test_parse_message_usage_basic(mock_anthropic_client: MagicMock) -> None:
"""Test _parse_message_usage with basic usage."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
usage = BetaUsage(input_tokens=10, output_tokens=5)
result = chat_client._parse_message_usage(usage)
assert result is not None
assert result.input_token_count == 10
assert result.output_token_count == 5
def test_parse_message_usage_none(mock_anthropic_client: MagicMock) -> None:
"""Test _parse_message_usage with None usage."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
result = chat_client._parse_message_usage(None)
assert result is None
def test_parse_message_contents_text(mock_anthropic_client: MagicMock) -> None:
"""Test _parse_message_contents with text content."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
content = [BetaTextBlock(type="text", text="Hello!")]
result = chat_client._parse_message_contents(content)
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "Hello!"
def test_parse_message_contents_tool_use(mock_anthropic_client: MagicMock) -> None:
"""Test _parse_message_contents with tool use."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
content = [
BetaToolUseBlock(
type="tool_use",
id="call_123",
name="get_weather",
input={"location": "SF"},
)
]
result = chat_client._parse_message_contents(content)
assert len(result) == 1
assert isinstance(result[0], FunctionCallContent)
assert result[0].call_id == "call_123"
assert result[0].name == "get_weather"
# Stream Processing Tests
def test_process_stream_event_simple(mock_anthropic_client: MagicMock) -> None:
"""Test _process_stream_event with simple mock event."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
# Test with a basic mock event - the actual implementation will handle real events
mock_event = MagicMock()
mock_event.type = "message_stop"
result = chat_client._process_stream_event(mock_event)
# message_stop events return None
assert result is None
async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None:
"""Test _inner_get_response method."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
# Create a mock message response
mock_message = MagicMock(spec=BetaMessage)
mock_message.id = "msg_test"
mock_message.model = "claude-3-5-sonnet-20241022"
mock_message.content = [BetaTextBlock(type="text", text="Hello!")]
mock_message.usage = BetaUsage(input_tokens=5, output_tokens=3)
mock_message.stop_reason = "end_turn"
mock_anthropic_client.beta.messages.create.return_value = mock_message
messages = [ChatMessage(role=Role.USER, text="Hi")]
chat_options = ChatOptions(max_tokens=10)
response = await chat_client._inner_get_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
)
assert response is not None
assert response.response_id == "msg_test"
assert len(response.messages) == 1
async def test_inner_get_streaming_response(mock_anthropic_client: MagicMock) -> None:
"""Test _inner_get_streaming_response method."""
chat_client = create_test_anthropic_client(mock_anthropic_client)
# Create mock streaming response
async def mock_stream():
mock_event = MagicMock()
mock_event.type = "message_stop"
yield mock_event
mock_anthropic_client.beta.messages.create.return_value = mock_stream()
messages = [ChatMessage(role=Role.USER, text="Hi")]
chat_options = ChatOptions(max_tokens=10)
chunks: list[ChatResponseUpdate] = []
async for chunk in chat_client._inner_get_streaming_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
):
if chunk:
chunks.append(chunk)
# We should get at least some response (even if empty due to message_stop)
assert isinstance(chunks, list)
# Integration Tests
@ai_function
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a location."""
return f"The weather in {location} is sunny and 72°F"
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_basic_chat() -> None:
"""Integration test for basic chat completion."""
client = AnthropicClient()
messages = [ChatMessage(role=Role.USER, text="Say 'Hello, World!' and nothing else.")]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
assert response is not None
assert len(response.messages) > 0
assert response.messages[0].role == Role.ASSISTANT
assert len(response.messages[0].text) > 0
assert response.usage_details is not None
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_streaming_chat() -> None:
"""Integration test for streaming chat completion."""
client = AnthropicClient()
messages = [ChatMessage(role=Role.USER, text="Count from 1 to 5.")]
chunks = []
async for chunk in client.get_streaming_response(messages=messages, chat_options=ChatOptions(max_tokens=50)):
chunks.append(chunk)
assert len(chunks) > 0
assert any(chunk.contents for chunk in chunks)
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_function_calling() -> None:
"""Integration test for function calling."""
client = AnthropicClient()
messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")]
tools = [get_weather]
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(tools=tools, max_tokens=100),
)
assert response is not None
# Should contain function call
has_function_call = any(
isinstance(content, FunctionCallContent) for msg in response.messages for content in msg.contents
)
assert has_function_call
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_with_system_message() -> None:
"""Integration test with system message."""
client = AnthropicClient()
messages = [
ChatMessage(role=Role.SYSTEM, text="You are a pirate. Always respond like a pirate."),
ChatMessage(role=Role.USER, text="Hello!"),
]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
assert response is not None
assert len(response.messages) > 0
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_temperature_control() -> None:
"""Integration test with temperature control."""
client = AnthropicClient()
messages = [ChatMessage(role=Role.USER, text="Say hello.")]
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(max_tokens=20, temperature=0.0),
)
assert response is not None
assert response.messages[0].text is not None
@pytest.mark.flaky
@skip_if_anthropic_integration_tests_disabled
async def test_anthropic_client_integration_ordering() -> None:
"""Integration test with ordering."""
client = AnthropicClient()
messages = [
ChatMessage(role=Role.USER, text="Say hello."),
ChatMessage(role=Role.USER, text="Then say goodbye."),
ChatMessage(role=Role.ASSISTANT, text="Thank you for chatting!"),
ChatMessage(role=Role.ASSISTANT, text="Let me know if I can help."),
ChatMessage(role=Role.USER, text="Just testing things."),
]
response = await client.get_response(messages=messages)
assert response is not None
assert response.messages[0].text is not None
@@ -735,10 +735,10 @@ class AzureAIAgentClient(BaseChatClient):
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE.mode
chat_options.tool_choice = ToolMode.NONE
return
chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode
chat_options.tool_choice = chat_tool_mode
async def _create_run_options(
self,
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251028"
version = "1.0.0b251104"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -559,19 +559,6 @@ async def test_azure_ai_chat_client_create_run_options_with_messages(mock_ai_pro
assert len(run_options["additional_messages"]) == 1 # Only user message
async def test_azure_ai_chat_client_instructions_sent_once(mock_ai_project_client: MagicMock) -> None:
"""Ensure instructions are only sent once for AzureAIAgentClient."""
chat_client = create_test_azure_ai_chat_client(mock_ai_project_client)
instructions = "You are a helpful assistant."
chat_options = ChatOptions(instructions=instructions)
messages = chat_client.prepare_messages([ChatMessage(role=Role.USER, text="Hello")], chat_options)
run_options, _ = await chat_client._create_run_options(messages, chat_options) # type: ignore
assert run_options.get("instructions") == instructions
async def test_azure_ai_chat_client_inner_get_response(mock_ai_project_client: MagicMock) -> None:
"""Test _inner_get_response method."""
chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, agent_id="test-agent")
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251028"
version = "1.0.0b251104"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -20,13 +20,7 @@ from ._middleware import (
from ._serialization import SerializationMixin
from ._threads import ChatMessageStoreProtocol
from ._tools import ToolProtocol
from ._types import (
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ToolMode,
)
from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ToolMode, prepare_messages
if TYPE_CHECKING:
from ._agents import ChatAgent
@@ -216,28 +210,7 @@ class ChatClientProtocol(Protocol):
# region ChatClientBase
def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]:
"""Convert various message input formats into a list of ChatMessage objects.
Args:
messages: The input messages in various supported formats.
Returns:
A list of ChatMessage objects.
"""
if isinstance(messages, str):
return [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
return [messages]
return_messages: list[ChatMessage] = []
for msg in messages:
if isinstance(msg, str):
msg = ChatMessage(role="user", text=msg)
return_messages.append(msg)
return return_messages
def merge_chat_options(
def _merge_chat_options(
*,
base_chat_options: ChatOptions | Any | None,
model_id: str | None = None,
@@ -405,25 +378,6 @@ class BaseChatClient(SerializationMixin, ABC):
return result
def prepare_messages(
self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions
) -> MutableSequence[ChatMessage]:
"""Convert various message input formats into a list of ChatMessage objects.
Prepends system instructions if present in chat_options.
Args:
messages: The input messages in various supported formats.
chat_options: The chat options containing instructions and other settings.
Returns:
A mutable sequence of ChatMessage objects.
"""
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
return [system_msg, *prepare_messages(messages)]
return prepare_messages(messages)
def _filter_internal_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Filter out internal framework parameters that shouldn't be passed to chat client implementations.
@@ -584,7 +538,7 @@ class BaseChatClient(SerializationMixin, ABC):
"""
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = merge_chat_options(
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
frequency_penalty=frequency_penalty,
@@ -612,7 +566,11 @@ class BaseChatClient(SerializationMixin, ABC):
)
chat_options.store = True
prepped_messages = self.prepare_messages(messages, chat_options)
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
prepped_messages = [system_msg, *prepare_messages(messages)]
else:
prepped_messages = prepare_messages(messages)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
@@ -679,7 +637,7 @@ class BaseChatClient(SerializationMixin, ABC):
"""
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = merge_chat_options(
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
frequency_penalty=frequency_penalty,
@@ -707,7 +665,11 @@ class BaseChatClient(SerializationMixin, ABC):
)
chat_options.store = True
prepped_messages = self.prepare_messages(messages, chat_options)
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
prepped_messages = [system_msg, *prepare_messages(messages)]
else:
prepped_messages = prepare_messages(messages)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
@@ -728,12 +690,12 @@ class BaseChatClient(SerializationMixin, ABC):
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE.mode
chat_options.tool_choice = ToolMode.NONE
return
if not chat_options.tools:
chat_options.tool_choice = ToolMode.NONE.mode
chat_options.tool_choice = ToolMode.NONE
else:
chat_options.tool_choice = chat_tool_mode.mode if isinstance(chat_tool_mode, ToolMode) else chat_tool_mode
chat_options.tool_choice = chat_tool_mode
def service_url(self) -> str:
"""Get the URL of the service.
@@ -8,7 +8,7 @@ from functools import update_wrapper
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeAlias, TypeVar
from ._serialization import SerializationMixin
from ._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
from ._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, prepare_messages
from .exceptions import MiddlewareException
if TYPE_CHECKING:
@@ -1375,7 +1375,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
context = ChatContext(
chat_client=self,
messages=self.prepare_messages(messages, chat_options),
messages=prepare_messages(messages),
chat_options=chat_options,
is_streaming=False,
kwargs=kwargs,
@@ -1425,7 +1425,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
pipeline = ChatMiddlewarePipeline(all_middleware) # type: ignore[arg-type]
context = ChatContext(
chat_client=self,
messages=self.prepare_messages(messages, chat_options),
messages=prepare_messages(messages),
chat_options=chat_options,
is_streaming=True,
kwargs=kwargs,
@@ -1318,13 +1318,13 @@ def _handle_function_calls_response(
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
**kwargs: Any,
) -> "ChatResponse":
from ._clients import prepare_messages
from ._middleware import extract_and_merge_function_middleware
from ._types import (
ChatMessage,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
# Extract and merge function middleware from chat client with kwargs pipeline
@@ -1465,7 +1465,6 @@ def _handle_function_calls_streaming_response(
**kwargs: Any,
) -> AsyncIterable["ChatResponseUpdate"]:
"""Wrap the inner get streaming response method to handle tool calls."""
from ._clients import prepare_messages
from ._middleware import extract_and_merge_function_middleware
from ._types import (
ChatMessage,
@@ -1473,6 +1472,7 @@ def _handle_function_calls_streaming_response(
ChatResponseUpdate,
FunctionCallContent,
FunctionResultContent,
prepare_messages,
)
# Extract and merge function middleware from chat client with kwargs pipeline
+36 -15
View File
@@ -561,7 +561,7 @@ class BaseContent(SerializationMixin):
def __init__(
self,
*,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -651,7 +651,7 @@ class TextContent(BaseContent):
*,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
**kwargs: Any,
):
"""Initializes a TextContent instance.
@@ -793,7 +793,7 @@ class TextReasoningContent(BaseContent):
*,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
**kwargs: Any,
):
"""Initializes a TextReasoningContent instance.
@@ -936,7 +936,7 @@ class DataContent(BaseContent):
self,
*,
uri: str,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -962,7 +962,7 @@ class DataContent(BaseContent):
*,
data: bytes,
media_type: str,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -989,7 +989,7 @@ class DataContent(BaseContent):
uri: str | None = None,
data: bytes | None = None,
media_type: str | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1093,7 +1093,7 @@ class UriContent(BaseContent):
uri: str,
media_type: str,
*,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1187,7 +1187,7 @@ class ErrorContent(BaseContent):
message: str | None = None,
error_code: str | None = None,
details: str | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1271,7 +1271,7 @@ class FunctionCallContent(BaseContent):
name: str,
arguments: str | dict[str, Any | None] | None = None,
exception: Exception | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1380,7 +1380,7 @@ class FunctionResultContent(BaseContent):
call_id: str,
result: Any | None = None,
exception: Exception | None = None,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1438,7 +1438,7 @@ class UsageContent(BaseContent):
self,
details: UsageDetails | MutableMapping[str, Any],
*,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1556,7 +1556,7 @@ class BaseUserInputRequest(BaseContent):
self,
*,
id: str,
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1610,7 +1610,7 @@ class FunctionApprovalResponseContent(BaseContent):
*,
id: str,
function_call: FunctionCallContent | MutableMapping[str, Any],
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1674,7 +1674,7 @@ class FunctionApprovalRequestContent(BaseContent):
*,
id: str,
function_call: FunctionCallContent | MutableMapping[str, Any],
annotations: list[Annotations | MutableMapping[str, Any]] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -2052,6 +2052,27 @@ class ChatMessage(SerializationMixin):
return " ".join(content.text for content in self.contents if isinstance(content, TextContent))
def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]) -> list[ChatMessage]:
"""Convert various message input formats into a list of ChatMessage objects.
Args:
messages: The input messages in various supported formats.
Returns:
A list of ChatMessage objects.
"""
if isinstance(messages, str):
return [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
return [messages]
return_messages: list[ChatMessage] = []
for msg in messages:
if isinstance(msg, str):
msg = ChatMessage(role="user", text=msg)
return_messages.append(msg)
return return_messages
# region ChatResponse
@@ -3125,7 +3146,7 @@ class ChatOptions(SerializationMixin):
@classmethod
def _validate_tool_mode(
cls, tool_choice: ToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None
) -> ToolMode | str | None:
) -> ToolMode | None:
"""Validates the tool_choice field to ensure it is a valid ToolMode."""
if not tool_choice:
return None
@@ -60,10 +60,13 @@ class WorkflowAgent(BaseAgent):
@classmethod
def from_json(cls, raw: str) -> "WorkflowAgent.RequestInfoFunctionArgs":
data = json.loads(raw)
if not isinstance(data, dict):
try:
parsed: Any = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"RequestInfoFunctionArgs JSON payload is malformed: {exc}") from exc
if not isinstance(parsed, dict):
raise ValueError("RequestInfoFunctionArgs JSON payload must decode to a mapping")
return cls.from_dict(data)
return cls.from_dict(cast(dict[str, Any], parsed))
def __init__(
self,
@@ -80,6 +80,14 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
options = agent.chat_options
middleware = list(agent.middleware or [])
# Reconstruct the original tools list by combining regular tools with MCP tools.
# ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization,
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
all_tools = list(options.tools) if options.tools else []
if agent._local_mcp_tools:
all_tools.extend(agent._local_mcp_tools)
return ChatAgent(
chat_client=agent.chat_client,
instructions=options.instructions,
@@ -101,7 +109,7 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
store=options.store,
temperature=options.temperature,
tool_choice=options.tool_choice, # type: ignore[arg-type]
tools=list(options.tools) if options.tools else None,
tools=all_tools if all_tools else None,
top_p=options.top_p,
user=options.user,
additional_chat_options=dict(options.additional_properties),
@@ -336,10 +344,15 @@ class _HandoffCoordinator(BaseGroupChatOrchestrator):
if await self._check_termination():
logger.info("Handoff workflow termination condition met. Ending conversation.")
await ctx.yield_output(list(conversation))
# Clean the output conversation for display
cleaned_output = clean_conversation_for_handoff(conversation)
await ctx.yield_output(cleaned_output)
return
await ctx.send_message(list(conversation), target_id=self._input_gateway_id)
# Clean conversation before sending to gateway for user input request
# This removes tool messages that shouldn't be shown to users
cleaned_for_display = clean_conversation_for_handoff(conversation)
await ctx.send_message(cleaned_for_display, target_id=self._input_gateway_id)
@handler
async def handle_user_input(
@@ -1274,12 +1287,12 @@ class HandoffBuilder:
updated_executor, tool_targets = self._prepare_agent_with_handoffs(executor, targets_map)
self._executors[source_exec_id] = updated_executor
handoff_tool_targets.update(tool_targets)
else:
# Default behavior: only coordinator gets handoff tools to all specialists
if isinstance(starting_executor, AgentExecutor) and specialists:
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
self._executors[self._starting_agent_id] = starting_executor
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
else:
# Default behavior: only coordinator gets handoff tools to all specialists
if isinstance(starting_executor, AgentExecutor) and specialists:
starting_executor, tool_targets = self._prepare_agent_with_handoffs(starting_executor, specialists)
self._executors[self._starting_agent_id] = starting_executor
handoff_tool_targets.update(tool_targets) # Update references after potential agent modifications
starting_executor = self._executors[self._starting_agent_id]
specialists = {
exec_id: executor for exec_id, executor in self._executors.items() if exec_id != self._starting_agent_id
@@ -2442,16 +2442,6 @@ class MagenticWorkflow:
f"Missing names: {missing}; unexpected names: {unexpected}."
)
async def run_stream_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> AsyncIterable[WorkflowEvent]:
"""Resume orchestration from a checkpoint and stream resulting events."""
await self._validate_checkpoint_participants(checkpoint_id, checkpoint_storage)
async for event in self._workflow.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
yield event
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
"""Run the workflow with a task string and return all events.
@@ -2495,32 +2485,6 @@ class MagenticWorkflow:
events.append(event)
return WorkflowRunResult(events)
async def run_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_storage: CheckpointStorage | None = None,
) -> WorkflowRunResult:
"""Resume orchestration from a checkpoint and collect all resulting events."""
events: list[WorkflowEvent] = []
async for event in self.run_stream_from_checkpoint(checkpoint_id, checkpoint_storage):
events.append(event)
return WorkflowRunResult(events)
async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]:
"""Forward responses to pending requests and stream resulting events.
This delegates to the underlying Workflow implementation.
"""
async for event in self._workflow.send_responses_streaming(responses):
yield event
async def send_responses(self, responses: dict[str, Any]) -> WorkflowRunResult:
"""Forward responses to pending requests and return all resulting events.
This delegates to the underlying Workflow implementation.
"""
return await self._workflow.send_responses(responses)
def __getattr__(self, name: str) -> Any:
"""Delegate unknown attributes to the underlying workflow."""
return getattr(self._workflow, name)
@@ -63,11 +63,12 @@ def clean_conversation_for_handoff(conversation: list[ChatMessage]) -> list[Chat
# Has tool content - only keep if it also has text
if msg.text and msg.text.strip():
# Create fresh text-only message
# Create fresh text-only message while preserving additional_properties
msg_copy = ChatMessage(
role=msg.role,
text=msg.text,
author_name=msg.author_name,
additional_properties=dict(msg.additional_properties) if msg.additional_properties else None,
)
cleaned.append(msg_copy)

Some files were not shown because too many files have changed in this diff Show More