mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Merge branch 'main' into dmkorolev/agentthreadstorages
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
|
||||
// capabilities to an AI agent. The provider runs a search against an external knowledge base
|
||||
// before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation and keep a short rolling window of conversation context.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
// The mock search inspects the user's question and returns pre-defined snippets
|
||||
// that resemble documents stored in an external knowledge source.
|
||||
List<TextSearchProvider.TextSearchResult> results = new();
|
||||
|
||||
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context.
|
||||
|
||||
Key features:
|
||||
- Configuring TextSearchProvider with custom search behavior
|
||||
- Running searches before AI invocations to provide relevant context
|
||||
- Managing conversation memory with a rolling window approach
|
||||
- Citing source documents in AI responses
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure OpenAI endpoint configured
|
||||
2. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure OpenAI endpoint
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
|
||||
|
||||
# Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The sample uses a mock search function that demonstrates the RAG pattern:
|
||||
|
||||
1. When the user asks a question, the TextSearchProvider intercepts it
|
||||
2. The search function looks for relevant documents based on the query
|
||||
3. Retrieved documents are injected into the model's context
|
||||
4. The AI responds using both its training and the provided context
|
||||
5. The agent can cite specific source documents in its answers
|
||||
|
||||
The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.).
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to integrate AI agents into a workflow pipeline.
|
||||
// Three translation agents are connected sequentially to create a translation chain:
|
||||
// English → French → Spanish → English, showing how agents can be composed as workflow executors.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
|
||||
// Create agents
|
||||
AIAgent frenchAgent = GetTranslationAgent("French", chatClient);
|
||||
AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient);
|
||||
AIAgent englishAgent = GetTranslationAgent("English", chatClient);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
Workflow workflow = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build();
|
||||
|
||||
// Execute the workflow
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
|
||||
|
||||
// Must send the turn token to trigger the agents.
|
||||
// The agents are wrapped as executors. When they receive messages,
|
||||
// they will cache the messages and only start processing when they receive a TurnToken.
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is AgentRunUpdateEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
|
||||
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
@@ -0,0 +1,26 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates the use of AI agents as executors within a workflow.
|
||||
|
||||
This workflow uses three translation agents:
|
||||
1. French Agent - translates input text to French
|
||||
2. Spanish Agent - translates French text to Spanish
|
||||
3. English Agent - translates Spanish text back to English
|
||||
|
||||
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
@@ -23,7 +23,7 @@ A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
|
||||
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
|
||||
|
||||
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
|
||||
AIAgent a2aAgent = await agentCard.GetAIAgentAsync();
|
||||
AIAgent a2aAgent = agentCard.GetAIAgent();
|
||||
|
||||
// Create the main agent, and provide the a2a agent skills as a function tools.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
|
||||
@@ -125,7 +125,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
|
||||
instructions: "You are a helpful assistant that provides concise and informative responses.",
|
||||
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(SourceName) // enable telemetry at the agent level
|
||||
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
var thread = agent.GetNewThread();
|
||||
@@ -134,6 +134,8 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent.
|
||||
|
||||
// Create a parent span for the entire agent session
|
||||
using var sessionActivity = activitySource.StartActivity("Agent Session");
|
||||
Console.WriteLine($"Trace ID: {sessionActivity?.TraceId} ");
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
sessionActivity?
|
||||
.SetTag("agent.name", "OpenTelemetryDemoAgent")
|
||||
@@ -147,7 +149,7 @@ using (appLogger.BeginScope(new Dictionary<string, object> { ["SessionId"] = ses
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("You: ");
|
||||
Console.Write("You (or 'exit' to quit): ");
|
||||
var userInput = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent.
|
||||
// The sample uses an In-Memory vector store, which can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
|
||||
// The TextSearchProvider runs a search against the vector store via the TextSearchStore before each model invocation and injects the results into the model context.
|
||||
// The TextSearchStore is a sample store implementation that hardcodes a storage schema and uses the vector store to store and retrieve documents.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Agents.AI.Samples;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using OpenAI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
|
||||
AzureOpenAIClient azureOpenAIClient = new(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential());
|
||||
|
||||
// Create an In-Memory vector store that uses the Azure OpenAI embedding model to generate embeddings.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new()
|
||||
{
|
||||
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
// Create a store that defines a storage schema, and uses the vector store to store and retrieve documents.
|
||||
TextSearchStore textSearchStore = new(vectorStore, "product-and-policy-info", 3072);
|
||||
|
||||
// Upload sample documents into the store.
|
||||
await textSearchStore.UpsertDocumentsAsync(GetSampleDocuments());
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the TextSearchStore.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
{
|
||||
// Here we are limiting the search results to the single top result to demonstrate that we are accurately matching
|
||||
// specific search results for each question, but in a real world case, more results should be used.
|
||||
var searchResults = await textSearchStore.SearchAsync(text, 1, ct);
|
||||
return searchResults.Select(r => new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = r.SourceName,
|
||||
SourceLink = r.SourceLink,
|
||||
Text = r.Text ?? string.Empty,
|
||||
RawRepresentation = r
|
||||
});
|
||||
};
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
};
|
||||
|
||||
// Create the AI agent with the TextSearchProvider as the AI context provider.
|
||||
AIAgent agent = azureOpenAIClient
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
// Produces some sample search documents.
|
||||
// Each one contains a source name and link, which the agent can use to cite sources in its responses.
|
||||
static IEnumerable<TextSearchDocument> GetSampleDocuments()
|
||||
{
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "return-policy-001",
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
};
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "shipping-guide-001",
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
};
|
||||
yield return new TextSearchDocument
|
||||
{
|
||||
SourceId = "tent-care-001",
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
};
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a document that can be used for Retrieval Augmented Generation (RAG) that stores textual data.
|
||||
/// </summary>
|
||||
public sealed class TextSearchDocument
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional list of namespaces that the document should belong to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A namespace is a logical grouping of documents, e.g. may include a group id to scope the document to a specific group of users.
|
||||
/// </remarks>
|
||||
public IList<string> Namespaces { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content as text.
|
||||
/// </summary>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional source ID for the document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This ID should be unique within the collection that the document is stored in, and can
|
||||
/// be used to map back to the source artifact for this document.
|
||||
/// If updates need to be made later or the source document was deleted and this document
|
||||
/// also needs to be deleted, this id can be used to find the document again.
|
||||
/// </remarks>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional name for the source document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to provide display names for citation links when the document is referenced as
|
||||
/// part of a response to a query.
|
||||
/// </remarks>
|
||||
public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional link back to the source of the document.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This can be used to provide citation links when the document is referenced as
|
||||
/// part of a response to a query.
|
||||
/// </remarks>
|
||||
public string? SourceLink { get; set; }
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// A class that allows for easy storage and retrieval of documents in a Vector Store for Retrieval Augmented Generation (RAG).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This class provides an opinionated schema for storing documents in a vector store. It is valuable for simple scenarios
|
||||
/// where you want to store text + embedding, or a reference to an external document + embedding without needing to customize the schema.
|
||||
/// If you want to control the schema yourself, use an implementation of <see cref="VectorStoreCollection{TKey, TRecord}"/> directly instead.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This class and its related types are currently provided as a sample implementation, but may be promoted to a first-class supported API in future releases.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class TextSearchStore : IDisposable
|
||||
{
|
||||
#if NET
|
||||
[GeneratedRegex(@"\p{L}+", RegexOptions.IgnoreCase, "en-US")]
|
||||
private static partial Regex AnyLanguageWordRegex();
|
||||
|
||||
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text => AnyLanguageWordRegex().Matches(text).Select(x => x.Value).ToList();
|
||||
#else
|
||||
private static readonly Regex s_anyLanguageWordRegex = new(@"\p{L}+", RegexOptions.Compiled);
|
||||
private static Regex AnyLanguageWordRegex() => s_anyLanguageWordRegex;
|
||||
|
||||
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text =>
|
||||
{
|
||||
List<string> words = new();
|
||||
foreach (Match word in AnyLanguageWordRegex().Matches(text))
|
||||
{
|
||||
words.Add(word.Value);
|
||||
}
|
||||
return words;
|
||||
};
|
||||
#endif
|
||||
|
||||
private readonly VectorStore _vectorStore;
|
||||
private readonly TextSearchStoreOptions _options;
|
||||
private readonly Func<string, ICollection<string>> _wordSegmenter;
|
||||
|
||||
private readonly VectorStoreCollection<object, Dictionary<string, object?>> _vectorStoreRecordCollection;
|
||||
private readonly SemaphoreSlim _collectionInitializationLock = new(1, 1);
|
||||
private bool _collectionInitialized;
|
||||
private bool _disposedValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextSearchStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="vectorStore">The vector store to store and read the memories from.</param>
|
||||
/// <param name="collectionName">The name of the collection in the vector store to store and read the memories from.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the memory embeddings.</param>
|
||||
/// <param name="options">Options to configure the behavior of this class.</param>
|
||||
/// <exception cref="NotSupportedException">Thrown if the key type provided is not supported.</exception>
|
||||
public TextSearchStore(
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
TextSearchStoreOptions? options = default)
|
||||
{
|
||||
// Verify
|
||||
if (vectorStore is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(vectorStore));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(collectionName))
|
||||
{
|
||||
throw new ArgumentException("Collection name cannot be null or whitespace.", nameof(collectionName));
|
||||
}
|
||||
|
||||
if (vectorDimensions < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(vectorDimensions), "Vector dimensions must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options?.KeyType is not null && options.KeyType != typeof(string) && options.KeyType != typeof(Guid))
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported key of type '{options.KeyType.Name}'");
|
||||
}
|
||||
|
||||
if (options?.KeyType is not null && options.KeyType != typeof(string) && options?.UseSourceIdAsPrimaryKey is true)
|
||||
{
|
||||
throw new NotSupportedException($"The {nameof(TextSearchStoreOptions.UseSourceIdAsPrimaryKey)} option can only be used when the key type is 'string'.");
|
||||
}
|
||||
|
||||
// Assign
|
||||
this._vectorStore = vectorStore;
|
||||
this._options = options ?? new TextSearchStoreOptions();
|
||||
this._wordSegmenter = this._options.WordSegmenter ?? s_defaultWordSegmenter;
|
||||
|
||||
// Create a definition so that we can use the dimensions provided at runtime.
|
||||
VectorStoreCollectionDefinition ragDocumentDefinition = new()
|
||||
{
|
||||
Properties = new List<VectorStoreProperty>()
|
||||
{
|
||||
new VectorStoreKeyProperty("Key", this._options.KeyType ?? typeof(string)),
|
||||
new VectorStoreDataProperty("Namespaces", typeof(List<string>)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("SourceId", typeof(string)) { IsIndexed = true },
|
||||
new VectorStoreDataProperty("Text", typeof(string)) { IsFullTextIndexed = true },
|
||||
new VectorStoreDataProperty("SourceName", typeof(string)),
|
||||
new VectorStoreDataProperty("SourceLink", typeof(string)),
|
||||
new VectorStoreVectorProperty("TextEmbedding", typeof(string), vectorDimensions),
|
||||
}
|
||||
};
|
||||
|
||||
this._vectorStoreRecordCollection = this._vectorStore.GetDynamicCollection(collectionName, ragDocumentDefinition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a batch of text chunks into the vector store.
|
||||
/// </summary>
|
||||
/// <param name="textChunks">The text chunks to upload.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that completes when the documents have been upserted.</returns>
|
||||
public async Task UpsertTextAsync(IEnumerable<string> textChunks, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (textChunks == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(textChunks));
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var storageDocuments = textChunks.Select(textChunk =>
|
||||
{
|
||||
// Without text we cannot generate a vector.
|
||||
if (string.IsNullOrWhiteSpace(textChunk))
|
||||
{
|
||||
throw new ArgumentException("One of the provided text chunks is null.", nameof(textChunks));
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
{ "Key", this.GenerateUniqueKey(null) },
|
||||
{ "Namespaces", new List<string>() },
|
||||
{ "Text", textChunk },
|
||||
{ "TextEmbedding", textChunk },
|
||||
};
|
||||
});
|
||||
|
||||
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a batch of documents into the vector store.
|
||||
/// </summary>
|
||||
/// <param name="documents">The documents to upload.</param>
|
||||
/// <param name="options">Optional options to control the upsert behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task that completes when the documents have been upserted.</returns>
|
||||
public async Task UpsertDocumentsAsync(IEnumerable<TextSearchDocument> documents, TextSearchStoreUpsertOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (documents is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(documents));
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var storageDocuments = documents.Select(document =>
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(documents), "One of the provided documents is null.");
|
||||
}
|
||||
|
||||
// Without text we cannot generate a vector.
|
||||
if (string.IsNullOrWhiteSpace(document.Text))
|
||||
{
|
||||
throw new ArgumentException($"The {nameof(TextSearchDocument.Text)} property must be set.", nameof(document));
|
||||
}
|
||||
|
||||
// If we aren't persisting the text, we need a source id or link to refer back to the original document.
|
||||
if (options?.DoNotPersistSourceText is true && string.IsNullOrWhiteSpace(document.SourceId) && string.IsNullOrWhiteSpace(document.SourceLink))
|
||||
{
|
||||
throw new ArgumentException($"Either the {nameof(TextSearchDocument.SourceId)} or {nameof(TextSearchDocument.SourceLink)} properties must be set when the {nameof(TextSearchStoreUpsertOptions.DoNotPersistSourceText)} setting is true.", nameof(document));
|
||||
}
|
||||
|
||||
var key = this.GenerateUniqueKey(this._options.UseSourceIdAsPrimaryKey ?? false ? document.SourceId : null);
|
||||
|
||||
return new Dictionary<string, object?>()
|
||||
{
|
||||
{ "Key", key },
|
||||
{ "Namespaces", document.Namespaces.ToList() },
|
||||
{ "SourceId", document.SourceId },
|
||||
{ "Text", options?.DoNotPersistSourceText is true ? null : document.Text },
|
||||
{ "SourceName", document.SourceName },
|
||||
{ "SourceLink", document.SourceLink },
|
||||
{ "TextEmbedding", document.Text },
|
||||
};
|
||||
});
|
||||
|
||||
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search the database for documents similar to the provided query.
|
||||
/// </summary>
|
||||
/// <param name="query">The text query to find similar documents to.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The search results.</returns>
|
||||
public async Task<IEnumerable<TextSearchDocument>> SearchAsync(string query, int top, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var searchResult = await this.SearchCoreAsync(query, top, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return searchResult.Select(x => new TextSearchDocument()
|
||||
{
|
||||
Namespaces = (List<string>)x["Namespaces"]!,
|
||||
Text = (string?)x["Text"],
|
||||
SourceId = (string?)x["SourceId"],
|
||||
SourceName = (string?)x["SourceName"],
|
||||
SourceLink = (string?)x["SourceLink"],
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal search implementation with hydration of id / link only storage.
|
||||
/// </summary>
|
||||
/// <param name="query">The text query to find similar documents to.</param>
|
||||
/// <param name="top">The maximum number of results to return.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The search results.</returns>
|
||||
private async Task<IEnumerable<Dictionary<string, object?>>> SearchCoreAsync(string query, int top, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Short circuit if the query is empty.
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If the user has not opted out of hybrid search, check if the vector store supports it.
|
||||
var hybridSearchCollection = this._options.UseHybridSearch ?? true ?
|
||||
vectorStoreRecordCollection.GetService(typeof(IKeywordHybridSearchable<Dictionary<string, object?>>)) as IKeywordHybridSearchable<Dictionary<string, object?>> :
|
||||
null;
|
||||
|
||||
// Optional filter to limit the search to a specific namespace.
|
||||
Expression<Func<Dictionary<string, object?>, bool>>? filter = string.IsNullOrWhiteSpace(this._options.SearchNamespace) ? null : x => ((List<string>)x["Namespaces"]!).Contains(this._options.SearchNamespace);
|
||||
|
||||
// Execute a hybrid search if possible, otherwise perform a regular vector search.
|
||||
var searchResult = hybridSearchCollection is null
|
||||
? vectorStoreRecordCollection.SearchAsync(
|
||||
query,
|
||||
top,
|
||||
options: new()
|
||||
{
|
||||
Filter = filter,
|
||||
},
|
||||
cancellationToken: cancellationToken)
|
||||
: hybridSearchCollection.HybridSearchAsync(
|
||||
query,
|
||||
this._wordSegmenter(query),
|
||||
top,
|
||||
options: new()
|
||||
{
|
||||
Filter = filter,
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Retrieve the documents from the search results.
|
||||
List<Dictionary<string, object?>> searchResponseDocs = new();
|
||||
await foreach (var searchResponseDoc in searchResult.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
searchResponseDocs.Add(searchResponseDoc.Record);
|
||||
}
|
||||
|
||||
// Find any source ids and links for which the text needs to be retrieved.
|
||||
var sourceIdsToRetrieve = searchResponseDocs
|
||||
.Where(x => string.IsNullOrWhiteSpace((string?)x["Text"]))
|
||||
.Select(x => new TextSearchStoreOptions.SourceRetrievalRequest((string?)x["SourceId"], (string?)x["SourceLink"]))
|
||||
.ToList();
|
||||
|
||||
// If we have none, we can return early.
|
||||
if (sourceIdsToRetrieve.Count == 0)
|
||||
{
|
||||
return searchResponseDocs;
|
||||
}
|
||||
|
||||
if (this._options.SourceRetrievalCallback is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} option must be set if retrieving documents without stored text.");
|
||||
}
|
||||
|
||||
// Retrieve the source text for the documents that need it.
|
||||
var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false);
|
||||
|
||||
if (retrievalResponses is null)
|
||||
{
|
||||
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} must return a non-null value.");
|
||||
}
|
||||
|
||||
// Update the retrieved documents with the retrieved text.
|
||||
return searchResponseDocs.GroupJoin(
|
||||
retrievalResponses,
|
||||
searchResponseDoc => (searchResponseDoc["SourceId"], searchResponseDoc["SourceLink"]),
|
||||
retrievalResponse => (retrievalResponse.SourceId, retrievalResponse.SourceLink),
|
||||
(searchResponseDoc, textRetrievalResponse) => (searchResponseDoc, textRetrievalResponse))
|
||||
.SelectMany(
|
||||
joinedSet => joinedSet.textRetrievalResponse.DefaultIfEmpty(),
|
||||
(combined, textRetrievalResponse) =>
|
||||
{
|
||||
combined.searchResponseDoc["Text"] = textRetrievalResponse?.Text ?? combined.searchResponseDoc["Text"];
|
||||
return combined.searchResponseDoc;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread safe method to get the collection and ensure that it is created at least once.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The created collection.</returns>
|
||||
private async Task<VectorStoreCollection<object, Dictionary<string, object?>>> EnsureCollectionExistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Return immediately if the collection is already created, no need to do any locking in this case.
|
||||
if (this._collectionInitialized)
|
||||
{
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
// Wait on a lock to ensure that only one thread can create the collection.
|
||||
await this._collectionInitializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// If multiple threads waited on the lock, and the first already created the collection,
|
||||
// we can return immediately without doing any work in subsequent threads.
|
||||
if (this._collectionInitialized)
|
||||
{
|
||||
this._collectionInitializationLock.Release();
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
// Only the winning thread should reach this point and create the collection.
|
||||
try
|
||||
{
|
||||
await this._vectorStoreRecordCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
|
||||
this._collectionInitialized = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._collectionInitializationLock.Release();
|
||||
}
|
||||
|
||||
return this._vectorStoreRecordCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a unique key for the RAG document.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">Source id of the source document for this RAG document.</param>
|
||||
/// <returns>A new unique key.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown if the requested key type is not supported.</exception>
|
||||
private object GenerateUniqueKey(string? sourceId)
|
||||
=> this._options.KeyType switch
|
||||
{
|
||||
_ when (this._options.KeyType == null || this._options.KeyType == typeof(string)) && !string.IsNullOrWhiteSpace(sourceId) => sourceId!,
|
||||
_ when this._options.KeyType == null || this._options.KeyType == typeof(string) => Guid.NewGuid().ToString(),
|
||||
_ when this._options.KeyType == typeof(Guid) => Guid.NewGuid(),
|
||||
|
||||
_ => throw new NotSupportedException($"Unsupported key of type '{this._options.KeyType.Name}'")
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!this._disposedValue)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
this._vectorStoreRecordCollection.Dispose();
|
||||
this._collectionInitializationLock.Dispose();
|
||||
}
|
||||
|
||||
this._disposedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
|
||||
this.Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Contains options for the <see cref="TextSearchStore"/>.
|
||||
/// </summary>
|
||||
public sealed class TextSearchStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets an optional namespace to pre-filter the possible
|
||||
/// records with when doing a vector search.
|
||||
/// </summary>
|
||||
public string? SearchNamespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use the source ID as the primary key for records.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Using the source ID as the primary key allows for easy updates from the source for any changed
|
||||
/// records, since those records can just be upserted again, and will overwrite the previous version
|
||||
/// of the same record.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This setting can only be used when the chosen key type is a string.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// Defaults to <c>false</c> if not set.
|
||||
/// </value>
|
||||
public bool? UseSourceIdAsPrimaryKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use hybrid search if it is available for the provided vector store.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <c>true</c> if not set.
|
||||
/// </value>
|
||||
public bool? UseHybridSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a word segmenter function to split search text into separate words for the purposes of hybrid search.
|
||||
/// This will not be used if <see cref="UseHybridSearch"/> is set to <c>false</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to a simple text-character-based segmenter that splits the text by any character that is not a text character.
|
||||
/// </remarks>
|
||||
public Func<string, ICollection<string>>? WordSegmenter { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of key to use for records in the text search store.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Make sure to pick a key type that is supported by the underlying vector store.
|
||||
/// Note that you have to choose <see cref="string"/> when using <see cref="UseSourceIdAsPrimaryKey"/>.
|
||||
/// </remarks>
|
||||
/// <value>Defaults to <see cref="string"/> if not set. Only <see cref="string"/> and <see cref="Guid"/> is currently supported.</value>
|
||||
public Type? KeyType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional callback to load the source text using the source id or source link
|
||||
/// if the source text is not persisted in the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The response should include the source id or source link, as provided in the request,
|
||||
/// plus the source text loaded from the source.
|
||||
/// </remarks>
|
||||
public Func<List<SourceRetrievalRequest>, Task<IEnumerable<SourceRetrievalResponse>>>? SourceRetrievalCallback { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to the <see cref="SourceRetrievalCallback"/>.
|
||||
/// </summary>
|
||||
public sealed class SourceRetrievalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceRetrievalRequest"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sourceId">The source ID of the document to retrieve.</param>
|
||||
/// <param name="sourceLink">The source link of the document to retrieve.</param>
|
||||
public SourceRetrievalRequest(string? sourceId, string? sourceLink)
|
||||
{
|
||||
this.SourceId = sourceId;
|
||||
this.SourceLink = sourceLink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source ID of the document to retrieve.
|
||||
/// </summary>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source link of the document to retrieve.
|
||||
/// </summary>
|
||||
public string? SourceLink { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a response from the <see cref="SourceRetrievalCallback"/>.
|
||||
/// </summary>
|
||||
public sealed class SourceRetrievalResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SourceRetrievalResponse"/> class.
|
||||
/// </summary>
|
||||
/// <param name="request">The request matching this response.</param>
|
||||
/// <param name="text">The source text that was retrieved.</param>
|
||||
public SourceRetrievalResponse(SourceRetrievalRequest request, string text)
|
||||
{
|
||||
if (request == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
if (text == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
}
|
||||
|
||||
this.SourceId = request.SourceId;
|
||||
this.SourceLink = request.SourceLink;
|
||||
this.Text = text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source ID of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string? SourceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source link of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string? SourceLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source text of the document that was retrieved.
|
||||
/// </summary>
|
||||
public string Text { get; set; }
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Contains options for <see cref="TextSearchStore.UpsertDocumentsAsync(IEnumerable{TextSearchDocument}, TextSearchStoreUpsertOptions?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
public sealed class TextSearchStoreUpsertOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the source text should be persisted in the database.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// Defaults to <see langword="false"/> if not set.
|
||||
/// </value>
|
||||
public bool DoNotPersistSourceText { get; init; }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent.
|
||||
// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
|
||||
// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.Qdrant;
|
||||
using OpenAI;
|
||||
using Qdrant.Client;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md";
|
||||
var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md";
|
||||
|
||||
AzureOpenAIClient azureOpenAIClient = new(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential());
|
||||
|
||||
// Create a Qdrant vector store that uses the Azure OpenAI embedding model to generate embeddings.
|
||||
QdrantClient client = new("localhost");
|
||||
VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new()
|
||||
{
|
||||
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
// Create a collection and upsert some text into it.
|
||||
var documentationCollection = vectorStore.GetCollection<Guid, DocumentationChunk>("documentation");
|
||||
await documentationCollection.EnsureCollectionDeletedAsync(); // Clear out any data from previous runs.
|
||||
await documentationCollection.EnsureCollectionExistsAsync();
|
||||
await UploadDataFromMarkdown(afOverviewUrl, "Microsoft Agent Framework Overview", documentationCollection, 2000, 200);
|
||||
await UploadDataFromMarkdown(afMigrationUrl, "Semantic Kernel to Microsoft Agent Framework Migration Guide", documentationCollection, 2000, 200);
|
||||
|
||||
// Create an adapter function that the TextSearchProvider can use to run searches against the collection.
|
||||
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
|
||||
{
|
||||
List<TextSearchProvider.TextSearchResult> results = [];
|
||||
await foreach (var result in documentationCollection.SearchAsync(text, 5, cancellationToken: ct))
|
||||
{
|
||||
results.Add(new TextSearchProvider.TextSearchResult
|
||||
{
|
||||
SourceName = result.Record.SourceName,
|
||||
SourceLink = result.Record.SourceLink,
|
||||
Text = result.Record.Text ?? string.Empty,
|
||||
RawRepresentation = result
|
||||
});
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
// Configure the options for the TextSearchProvider.
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
// Use up to 4 recent messages when searching so that searches
|
||||
// still produce valuable results even when the user is referring
|
||||
// back to previous messages in their request.
|
||||
RecentMessageMemoryLimit = 5
|
||||
};
|
||||
|
||||
// Create the AI agent with the TextSearchProvider as the AI context provider.
|
||||
AIAgent agent = azureOpenAIClient
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(SearchAdapter, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about SK threads\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! How do I create a thread in Semantic Kernel?", thread));
|
||||
|
||||
// Here we are asking a very vague question when taken out of context,
|
||||
// but since we are including previous messages in our search using RecentMessageMemoryLimit
|
||||
// the RAG search should still produce useful results.
|
||||
Console.WriteLine("\n>> Asking about AF threads\n");
|
||||
Console.WriteLine(await agent.RunAsync("and in Agent Framework?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Contrasting Approaches\n");
|
||||
Console.WriteLine(await agent.RunAsync("Please contrast the two approaches", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about ancestry\n");
|
||||
Console.WriteLine(await agent.RunAsync("What are the predecessors to the Agent Framework?", thread));
|
||||
|
||||
static async Task UploadDataFromMarkdown(string markdownUrl, string sourceName, VectorStoreCollection<Guid, DocumentationChunk> vectorStoreCollection, int chunkSize, int overlap)
|
||||
{
|
||||
// Download the markdown from the given url.
|
||||
using HttpClient client = new();
|
||||
var markdown = await client.GetStringAsync(new Uri(markdownUrl));
|
||||
|
||||
// Chunk it into separate parts with some overlap between chunks
|
||||
var chunks = new List<DocumentationChunk>();
|
||||
for (int i = 0; i < markdown.Length; i += chunkSize)
|
||||
{
|
||||
var chunk = new DocumentationChunk
|
||||
{
|
||||
Key = Guid.NewGuid(),
|
||||
SourceLink = markdownUrl,
|
||||
SourceName = sourceName,
|
||||
Text = markdown.Substring(i, Math.Min(chunkSize + overlap, markdown.Length - i))
|
||||
};
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
|
||||
// Upsert each chunk into the provided vector store.
|
||||
await vectorStoreCollection.UpsertAsync(chunks);
|
||||
}
|
||||
|
||||
// Data model that defines the database schema we want to use.
|
||||
internal sealed class DocumentationChunk
|
||||
{
|
||||
[VectorStoreKey]
|
||||
public Guid Key { get; set; }
|
||||
[VectorStoreData]
|
||||
public string SourceLink { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string SourceName { get; set; } = string.Empty;
|
||||
[VectorStoreData]
|
||||
public string Text { get; set; } = string.Empty;
|
||||
[VectorStoreVector(Dimensions: 3072)]
|
||||
public string Embedding => this.Text;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
# Agent Framework Retrieval Augmented Generation (RAG) with an external Vector Store with a custom schema
|
||||
|
||||
This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store.
|
||||
It also uses a custom schema for the documents stored in the vector store.
|
||||
This sample uses Qdrant for the vector store, but this can easily be swapped out for any vector store that has a Microsoft.Extensions.VectorStore implementation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint
|
||||
- Both a chat completion and embedding deployment configured in the Azure OpenAI resource
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
|
||||
- An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally.
|
||||
|
||||
**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
|
||||
|
||||
**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Running the sample from the console
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-3-large" # Optional, defaults to text-embedding-3-large
|
||||
```
|
||||
|
||||
If the variables are not set, you will be prompted for the values when running the samples.
|
||||
|
||||
To use Qdrant in docker locally, start your Qdrant instance using the default port mappings.
|
||||
|
||||
```powershell
|
||||
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest
|
||||
```
|
||||
|
||||
Execute the following command to build the sample:
|
||||
|
||||
```powershell
|
||||
dotnet build
|
||||
```
|
||||
|
||||
Execute the following command to run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run --no-build
|
||||
```
|
||||
|
||||
Or just build and run in one step:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Running the sample from Visual Studio
|
||||
|
||||
Open the solution in Visual Studio and set the sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`.
|
||||
|
||||
You will be prompted for any required environment variables if they are not already set.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Agent Framework Retrieval Augmented Generation (RAG)
|
||||
|
||||
These samples show how to create an agent with the Agent Framework that uses Retrieval Augmented Generation (RAG) to enhance its responses with information from a knowledge base.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|
||||
|[RAG with external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.|
|
||||
@@ -28,7 +28,9 @@ AIAgent agent = new AzureOpenAIClient(
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
|
||||
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
@@ -52,9 +54,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "Contoso Outdoors Return Policy",
|
||||
Link = "https://contoso.com/policies/returns",
|
||||
Value = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,9 +64,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "Contoso Outdoors Shipping Guide",
|
||||
Link = "https://contoso.com/help/shipping",
|
||||
Value = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,9 +74,9 @@ static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(st
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
Name = "TrailRunner Tent Care Instructions",
|
||||
Link = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Value = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+85
-31
@@ -1,52 +1,106 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend.
|
||||
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool.
|
||||
// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
|
||||
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
|
||||
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_MODEL_ID") ?? "gpt-4.1-mini";
|
||||
var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4.1-mini";
|
||||
|
||||
// Get a client to create/retrieve server side agents with.
|
||||
var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential());
|
||||
|
||||
// **** MCP Tool with Auto Approval ****
|
||||
// *************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
var mcpTool = new MCPToolDefinition(
|
||||
serverLabel: "microsoft_learn",
|
||||
serverUrl: "https://learn.microsoft.com/api/mcp");
|
||||
mcpTool.AllowedTools.Add("microsoft_docs_search");
|
||||
|
||||
// Create a server side persistent agent with the Azure.AI.Agents.Persistent SDK.
|
||||
var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync(
|
||||
model: model,
|
||||
name: "MicrosoftLearnAgent",
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
tools: [mcpTool]);
|
||||
|
||||
// Retrieve an already created server side persistent agent as an AIAgent.
|
||||
AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id);
|
||||
|
||||
// Create run options to configure the agent invocation.
|
||||
var runOptions = new ChatClientAgentRunOptions()
|
||||
// In this case we allow the tool to always be called without approval.
|
||||
var mcpTool = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
RawRepresentationFactory = (_) => new ThreadAndRunOptions()
|
||||
{
|
||||
ToolResources = new MCPToolResource(serverLabel: "microsoft_learn")
|
||||
{
|
||||
RequireApproval = new MCPApproval("never"),
|
||||
}.ToToolResources()
|
||||
}
|
||||
}
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
|
||||
};
|
||||
|
||||
// Create a server side persistent agent with the mcp tool, and expose it as an AIAgent.
|
||||
AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync(
|
||||
model: model,
|
||||
options: new()
|
||||
{
|
||||
Name = "MicrosoftLearnAgent",
|
||||
Instructions = "You answer questions by searching the Microsoft Learn content only.",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Tools = [mcpTool]
|
||||
},
|
||||
});
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
var response = await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread, runOptions);
|
||||
Console.WriteLine(response);
|
||||
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
|
||||
|
||||
// Cleanup for sample purposes.
|
||||
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
|
||||
// **** MCP Tool with Approval Required ****
|
||||
// *****************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we require approval before the tool can be called.
|
||||
var mcpToolWithApproval = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAsync(
|
||||
model: model,
|
||||
options: new()
|
||||
{
|
||||
Name = "MicrosoftLearnAgentWithApproval",
|
||||
Instructions = "You answer questions by searching the Microsoft Learn content only.",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Tools = [mcpToolWithApproval]
|
||||
},
|
||||
});
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
ServerName: {approvalRequest.ToolCall.ServerName}
|
||||
Name: {approvalRequest.ToolCall.ToolName}
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
|
||||
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|---|---|
|
||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool.
|
||||
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
|
||||
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// **** MCP Tool with Auto Approval ****
|
||||
// *************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we allow the tool to always be called without approval.
|
||||
var mcpTool = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgent",
|
||||
tools: [mcpTool]);
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
|
||||
|
||||
// **** MCP Tool with Approval Required ****
|
||||
// *****************************************
|
||||
|
||||
// Create an MCP tool definition that the agent can use.
|
||||
// In this case we require approval before the tool can be called.
|
||||
var mcpToolWithApproval = new HostedMcpServerTool(
|
||||
serverName: "microsoft_learn",
|
||||
serverAddress: "https://learn.microsoft.com/api/mcp")
|
||||
{
|
||||
AllowedTools = ["microsoft_docs_search"],
|
||||
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
|
||||
};
|
||||
|
||||
// Create an agent based on Azure OpenAI Responses as the backend.
|
||||
AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgentWithApproval",
|
||||
tools: [mcpToolWithApproval]);
|
||||
|
||||
// You can then invoke the agent like any other AIAgent.
|
||||
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
|
||||
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
|
||||
var userInputRequests = response.UserInputRequests.ToList();
|
||||
|
||||
while (userInputRequests.Count > 0)
|
||||
{
|
||||
// Ask the user to approve each MCP call request.
|
||||
// For simplicity, we are assuming here that only MCP approval requests are being made.
|
||||
var userInputResponses = userInputRequests
|
||||
.OfType<McpServerToolApprovalRequestContent>()
|
||||
.Select(approvalRequest =>
|
||||
{
|
||||
Console.WriteLine($"""
|
||||
The agent would like to invoke the following MCP Tool, please reply Y to approve.
|
||||
ServerName: {approvalRequest.ToolCall.ServerName}
|
||||
Name: {approvalRequest.ToolCall.ToolName}
|
||||
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
|
||||
""");
|
||||
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Pass the user input responses back to the agent for further processing.
|
||||
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
|
||||
|
||||
userInputRequests = response.UserInputRequests.ToList();
|
||||
}
|
||||
|
||||
Console.WriteLine($"\nAgent: {response}");
|
||||
@@ -0,0 +1,17 @@
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini
|
||||
```
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentsSample;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concepts workflows as agents, where a workflow can be
|
||||
@@ -61,9 +61,9 @@ public static class Program
|
||||
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
|
||||
{
|
||||
if (update.MessageId is null)
|
||||
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
// skip updates that don't have a message ID
|
||||
// skip updates that don't have a message ID or text
|
||||
continue;
|
||||
}
|
||||
Console.Clear();
|
||||
|
||||
+20
-21
@@ -4,7 +4,7 @@ using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentsSample;
|
||||
namespace WorkflowAsAnAgentSample;
|
||||
|
||||
internal static class WorkflowFactory
|
||||
{
|
||||
@@ -41,44 +41,43 @@ internal static class WorkflowFactory
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentStartExecutor() :
|
||||
Executor<List<ChatMessage>>("ConcurrentStartExecutor")
|
||||
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
/// <param name="message">The user message to process</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
// Broadcast the message to all connected agents. Receiving agents will queue
|
||||
// the message but will not start processing until they receive a turn token.
|
||||
await context.SendMessageAsync(message, cancellationToken: cancellationToken);
|
||||
// Broadcast the turn token to kick off the agents.
|
||||
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken);
|
||||
return routeBuilder
|
||||
.AddHandler<List<ChatMessage>>(this.RouteMessages)
|
||||
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
|
||||
}
|
||||
|
||||
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentAggregationExecutor() :
|
||||
Executor<ChatMessage>("ConcurrentAggregationExecutor")
|
||||
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="message">The messages from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.Add(message);
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
|
||||
@@ -97,21 +97,21 @@ internal sealed class ConcurrentStartExecutor() :
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
internal sealed class ConcurrentAggregationExecutor() :
|
||||
Executor<ChatMessage>("ConcurrentAggregationExecutor")
|
||||
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="message">The messages from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A task representing the asynchronous operation</returns>
|
||||
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.Add(message);
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Azure.Monitor.OpenTelemetry.Exporter;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample shows how to enable OpenTelemetry observability for workflows when
|
||||
/// using them as <see cref="AIAgent"/>s.
|
||||
///
|
||||
/// In this example, we create a workflow that uses two language agents to process
|
||||
/// input concurrently, one that responds in French and another that responds in English.
|
||||
///
|
||||
/// You will interact with the workflow in an interactive loop, sending messages and receiving
|
||||
/// streaming responses from the workflow as if it were an agent who responds in both languages.
|
||||
///
|
||||
/// OpenTelemetry observability is enabled at multiple levels:
|
||||
/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service.
|
||||
/// 2. At the agent level, capturing telemetry for agent operations.
|
||||
/// 3. At the workflow level, capturing telemetry for workflow execution.
|
||||
///
|
||||
/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to
|
||||
/// Azure Monitor if an Application Insights connection string is provided.
|
||||
///
|
||||
/// Learn how to set up an Aspire dashboard here:
|
||||
/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - Foundational samples should be completed first.
|
||||
/// - This sample uses concurrent processing.
|
||||
/// - An Azure OpenAI endpoint and deployment name.
|
||||
/// - An Application Insights resource for telemetry (optional).
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private const string SourceName = "Workflow.ApplicationInsightsSample";
|
||||
private static readonly ActivitySource s_activitySource = new(SourceName);
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up observability
|
||||
var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
|
||||
var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317";
|
||||
|
||||
var resourceBuilder = ResourceBuilder
|
||||
.CreateDefault()
|
||||
.AddService("WorkflowSample");
|
||||
|
||||
var traceProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry
|
||||
.AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry
|
||||
.AddSource(SourceName);
|
||||
|
||||
traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString))
|
||||
{
|
||||
traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString);
|
||||
}
|
||||
|
||||
using var traceProvider = traceProviderBuilder.Build();
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level
|
||||
.Build();
|
||||
|
||||
// Start a root activity for the application
|
||||
using var activity = s_activitySource.StartActivity("main");
|
||||
Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}");
|
||||
|
||||
// Create the workflow and turn it into an agent with OpenTelemetry instrumentation
|
||||
var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName);
|
||||
var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName)
|
||||
{
|
||||
EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses
|
||||
};
|
||||
var thread = agent.GetNewThread();
|
||||
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.Write("User (or 'exit' to quit): ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessInputAsync(agent, thread, input);
|
||||
}
|
||||
|
||||
// Helper method to process user input and display streaming responses. To display
|
||||
// multiple interleaved responses correctly, we buffer updates by message ID and
|
||||
// re-render all messages on each update.
|
||||
static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input)
|
||||
{
|
||||
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
|
||||
{
|
||||
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
// skip updates that don't have a message ID or text
|
||||
continue;
|
||||
}
|
||||
Console.Clear();
|
||||
|
||||
if (!buffer.TryGetValue(update.MessageId, out List<AgentRunResponseUpdate>? value))
|
||||
{
|
||||
value = [];
|
||||
buffer[update.MessageId] = value;
|
||||
}
|
||||
value.Add(update);
|
||||
|
||||
foreach (var (messageId, segments) in buffer)
|
||||
{
|
||||
string combinedText = string.Concat(segments);
|
||||
Console.WriteLine($"{segments[0].AuthorName}: {combinedText}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
internal static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
internal static Workflow GetWorkflow(IChatClient chatClient, string sourceName)
|
||||
{
|
||||
// Create executors
|
||||
var startExecutor = new ConcurrentStartExecutor();
|
||||
var aggregationExecutor = new ConcurrentAggregationExecutor();
|
||||
AIAgent frenchAgent = GetLanguageAgent("French", chatClient, sourceName);
|
||||
AIAgent englishAgent = GetLanguageAgent("English", chatClient, sourceName);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a language agent for the specified target language.
|
||||
/// </summary>
|
||||
/// <param name="targetLanguage">The target language for translation</param>
|
||||
/// <param name="chatClient">The chat client to use for the agent</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>An AIAgent configured for the specified language</returns>
|
||||
private static AIAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient, string sourceName) =>
|
||||
new ChatClientAgent(
|
||||
chatClient,
|
||||
instructions: $"You're a helpful assistant who always responds in {targetLanguage}.",
|
||||
name: $"{targetLanguage}Agent"
|
||||
)
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
return routeBuilder
|
||||
.AddHandler<List<ChatMessage>>(this.RouteMessages)
|
||||
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
|
||||
}
|
||||
|
||||
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}"));
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -20,7 +20,9 @@ public static class Program
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
UppercaseExecutor uppercase = new();
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
@@ -40,23 +42,6 @@ public static class Program
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First executor: converts input text to uppercase.
|
||||
/// </summary>
|
||||
internal sealed class UppercaseExecutor() : Executor<string, string>("UppercaseExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by converting it to uppercase.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to convert</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The input text converted to uppercase</returns>
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class Program
|
||||
.Build();
|
||||
|
||||
// Step 2: Configure the sub-workflow as an executor for use in the parent workflow
|
||||
ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow");
|
||||
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("TextProcessingSubWorkflow");
|
||||
|
||||
// Step 3: Build a main workflow that uses the sub-workflow as an executor
|
||||
Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n");
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally
|
||||
2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions
|
||||
3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses
|
||||
4. **Building complex pipelines** - Chaining multiple heterogeneous components together
|
||||
|
||||
Reference in New Issue
Block a user