mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b3e2a7e82 | ||
|
|
d5040236c9 | ||
|
|
9e1b3c9b85 | ||
|
|
8b4aa1ebb5 | ||
|
|
ada5b83c80 | ||
|
|
64fc3f381f | ||
|
|
50d9b13bfc | ||
|
|
b0ee7028a6 | ||
|
|
12d17acdc0 | ||
|
|
a766a81243 | ||
|
|
e462d209fd | ||
|
|
0b843d2b3e | ||
|
|
b25b0af49b | ||
|
|
91c66f8a2c | ||
|
|
c83011b30d | ||
|
|
e87eed573b | ||
|
|
b467ddf92a | ||
|
|
a9a1b1e567 | ||
|
|
a5db641ce3 | ||
|
|
889f45a7ef | ||
|
|
68b6a55757 | ||
|
|
2101d9d36d | ||
|
|
1543370027 | ||
|
|
7431b46bf0 | ||
|
|
947e9eb1dc | ||
|
|
b9d3e51734 | ||
|
|
42f20b431c | ||
|
|
a24624e28e | ||
|
|
6e49c7a7b7 | ||
|
|
23b76b382f | ||
|
|
434ffb8ab8 | ||
|
|
abfdf75cfb | ||
|
|
fb7086b2e0 | ||
|
|
36532e929e | ||
|
|
2059e7b3e8 | ||
|
|
943d92674e | ||
|
|
f6eadd412e | ||
|
|
a2ee840eef | ||
|
|
8a3cab38ee | ||
|
|
6325873906 | ||
|
|
1fbdcf8268 | ||
|
|
00a78d7bc6 |
@@ -17,6 +17,7 @@ ignorePatterns:
|
||||
- pattern: "https://api.powerplatform.com/.default"
|
||||
- pattern: "https://your-resource.openai.azure.com/"
|
||||
- pattern: "http://host.docker.internal"
|
||||
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
|
||||
# excludedDirs:
|
||||
# Folders which include links to localhost, since it's not ignored with regular expressions
|
||||
baseUrl: https://github.com/microsoft/agent-framework/
|
||||
|
||||
@@ -16,8 +16,34 @@ env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
paths-filter:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
python:
|
||||
- 'python/**'
|
||||
# run only if 'python' files were changed
|
||||
- name: python tests
|
||||
if: steps.filter.outputs.python == 'true'
|
||||
run: echo "Python file"
|
||||
# run only if not 'python' files were changed
|
||||
- name: not python tests
|
||||
if: steps.filter.outputs.python != 'true'
|
||||
run: echo "NOT python file"
|
||||
|
||||
python-lab-tests:
|
||||
name: Python Lab Tests
|
||||
needs: paths-filter
|
||||
if: needs.paths-filter.outputs.pythonChanges == 'true'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
|
||||
@@ -60,6 +60,8 @@ jobs:
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
|
||||
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Welcome to the Agent Framework Community
|
||||
|
||||
Below are some ways that you can get involved in the Agent Framework Community.
|
||||
|
||||
## Engage on GitHub
|
||||
|
||||
- [Discussions](https://github.com/microsoft/agent-framework/discussions): Ask questions, provide feedback and ideas to what you'd like to see from the Agent Framework.
|
||||
- [Issues](https://github.com/microsoft/agent-framework/issues) - If you find a bug, unexpected behavior or have a feature request, please open an issue.
|
||||
- [Pull Requests](https://github.com/microsoft/agent-framework/pulls) - We welcome contributions! Please see our [Contributing Guide](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
||||
|
||||
We do our best to respond to each submission.
|
||||
|
||||
## Public Community Office Hours
|
||||
|
||||
We regularly have Community Office Hours that are open to the **public** to join.
|
||||
|
||||
Add Agent Framework events to your calendar. We are running two community calls to accommodate different time zones for Q&A Office Hours:
|
||||
|
||||
- **Americas & EMEA timezone:** Every Wednesday at 8:00 AM Pacific Time/17:00 CET. Adjusted for daylight savings. Join here: [AF-AG-SK-Americas-Europe-OfficeHours](https://aka.ms/sk-officehours).
|
||||
- **Asia Pacific timezone:** The second Wednesday of every month at 4:00 PM Pacific Time Wednesday. In much of Asia this occurs on Thursday local time. Adjusted for daylight savings. Join here: [AF-AG-SK-APAC-OfficeHours](https://aka.ms/sk-apac-officehours).
|
||||
|
||||
If you are unable to make it live, all meetings will be recorded and posted online.
|
||||
@@ -22,7 +22,6 @@ This document aims to provide options and capture the decision on how to model t
|
||||
See various features that would need to be supported via this type of mechanism, plus how various other frameworks support this:
|
||||
|
||||
- Also see [dotnet issue 6492](https://github.com/dotnet/extensions/issues/6492), which discusses the need for a similar pattern in the context of MCP approvals.
|
||||
- Also see [the openai RunToolApprovalItem](https://openai.github.io/openai-agents-js/openai/agents/classes/runtoolapprovalitem/).
|
||||
- Also see [the openai human-in-the-loop guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/#approval-requests).
|
||||
- Also see [the openai MCP guide](https://openai.github.io/openai-agents-js/guides/mcp/#optional-approval-flow).
|
||||
- Also see [MCP Approval Requests from OpenAI](https://platform.openai.com/docs/guides/tools-remote-mcp#approvals).
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.8.0" />
|
||||
<!-- Azure.* -->
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.6" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.7" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
@@ -40,18 +40,18 @@
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.10.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.10.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.10.0-preview.1.25513.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.1-preview.1.25521.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
@@ -65,9 +65,11 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
<!-- Vector Stores -->
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.66.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.66.0-preview" />
|
||||
@@ -83,9 +85,9 @@
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.5.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.6.0" />
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.77.1" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
|
||||
<!-- Workflows -->
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="1.2025.1003.2" />
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1003.2" />
|
||||
|
||||
@@ -58,16 +58,26 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
|
||||
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
|
||||
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
|
||||
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/ModelContextProtocol/">
|
||||
<File Path="samples/GettingStarted/ModelContextProtocol/README.md" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||
<Project Path="samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Observability/">
|
||||
<Project Path="samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
|
||||
@@ -120,6 +130,7 @@
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Observability/">
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
|
||||
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
|
||||
@@ -133,8 +144,10 @@
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj" />
|
||||
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/SemanticKernelMigration/">
|
||||
<File Path="samples/SemanticKernelMigration/README.md" />
|
||||
<Folder Name="/Samples/Catalog/">
|
||||
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
|
||||
<Project Path="samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj" />
|
||||
<Project Path="samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
@@ -294,4 +307,4 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
</Solution>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251028.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251028.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251028.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251104.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
|
||||
// capabilities to an AI agent. The provider runs a search against an external knowledge base
|
||||
// before each model invocation and injects the results into the model context.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
TextSearchProviderOptions textSearchOptions = new()
|
||||
{
|
||||
// Run the search prior to every model invocation and keep a short rolling window of conversation context.
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 6,
|
||||
};
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
|
||||
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
Console.WriteLine(">> Asking about returns\n");
|
||||
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about shipping\n");
|
||||
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Asking about product care\n");
|
||||
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
|
||||
|
||||
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
// The mock search inspects the user's question and returns pre-defined snippets
|
||||
// that resemble documents stored in an external knowledge source.
|
||||
List<TextSearchProvider.TextSearchResult> results = new();
|
||||
|
||||
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Return Policy",
|
||||
SourceLink = "https://contoso.com/policies/returns",
|
||||
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "Contoso Outdoors Shipping Guide",
|
||||
SourceLink = "https://contoso.com/help/shipping",
|
||||
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
results.Add(new()
|
||||
{
|
||||
SourceName = "TrailRunner Tent Care Instructions",
|
||||
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
|
||||
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context.
|
||||
|
||||
Key features:
|
||||
- Configuring TextSearchProvider with custom search behavior
|
||||
- Running searches before AI invocations to provide relevant context
|
||||
- Managing conversation memory with a rolling window approach
|
||||
- Citing source documents in AI responses
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure OpenAI endpoint configured
|
||||
2. A deployment of a chat model (e.g., gpt-4o-mini)
|
||||
3. Azure CLI installed and authenticated
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure OpenAI endpoint
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
|
||||
|
||||
# Optional, defaults to gpt-4o-mini
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
The sample uses a mock search function that demonstrates the RAG pattern:
|
||||
|
||||
1. When the user asks a question, the TextSearchProvider intercepts it
|
||||
2. The search function looks for relevant documents based on the query
|
||||
3. Retrieved documents are injected into the model's context
|
||||
4. The AI responds using both its training and the provided context
|
||||
5. The agent can cite specific source documents in its answers
|
||||
|
||||
The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.).
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to integrate AI agents into a workflow pipeline.
|
||||
// Three translation agents are connected sequentially to create a translation chain:
|
||||
// English → French → Spanish → English, showing how agents can be composed as workflow executors.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient();
|
||||
|
||||
// Create agents
|
||||
AIAgent frenchAgent = GetTranslationAgent("French", chatClient);
|
||||
AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient);
|
||||
AIAgent englishAgent = GetTranslationAgent("English", chatClient);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
Workflow workflow = new WorkflowBuilder(frenchAgent)
|
||||
.AddEdge(frenchAgent, spanishAgent)
|
||||
.AddEdge(spanishAgent, englishAgent)
|
||||
.Build();
|
||||
|
||||
// Execute the workflow
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
|
||||
|
||||
// Must send the turn token to trigger the agents.
|
||||
// The agents are wrapped as executors. When they receive messages,
|
||||
// they will cache the messages and only start processing when they receive a TurnToken.
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is AgentRunUpdateEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
|
||||
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
|
||||
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
|
||||
@@ -0,0 +1,26 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates the use of AI agents as executors within a workflow.
|
||||
|
||||
This workflow uses three translation agents:
|
||||
1. French Agent - translates input text to French
|
||||
2. Spanish Agent - translates French text to Spanish
|
||||
3. English Agent - translates Spanish text back to English
|
||||
|
||||
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- Azure OpenAI service endpoint and deployment configured
|
||||
- Azure CLI installed and authenticated (for Azure credential authentication)
|
||||
|
||||
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
@@ -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.Agents.Persistent" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool.
|
||||
|
||||
using Azure.AI.Agents.Persistent;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deepResearchDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME") ?? "o3-deep-research";
|
||||
var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
var bingConnectionId = Environment.GetEnvironmentVariable("BING_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CONNECTION_ID is not set.");
|
||||
|
||||
// Configure extended network timeout for long-running Deep Research tasks.
|
||||
PersistentAgentsAdministrationClientOptions persistentAgentsClientOptions = new();
|
||||
persistentAgentsClientOptions.Retry.NetworkTimeout = TimeSpan.FromMinutes(20);
|
||||
|
||||
// Get a client to create/retrieve server side agents with.
|
||||
PersistentAgentsClient persistentAgentsClient = new(endpoint, new AzureCliCredential(), persistentAgentsClientOptions);
|
||||
|
||||
// Define and configure the Deep Research tool.
|
||||
DeepResearchToolDefinition deepResearchTool = new(new DeepResearchDetails(
|
||||
bingGroundingConnections: [new(bingConnectionId)],
|
||||
model: deepResearchDeploymentName)
|
||||
);
|
||||
|
||||
// Create an agent with the Deep Research tool on the Azure AI agent service.
|
||||
AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync(
|
||||
model: modelDeploymentName,
|
||||
name: "DeepResearchAgent",
|
||||
instructions: "You are a helpful Agent that assists in researching scientific topics.",
|
||||
tools: [deepResearchTool]);
|
||||
|
||||
const string Task = "Research the current state of studies on orca intelligence and orca language, " +
|
||||
"including what is currently known about orcas' cognitive capabilities and communication systems.";
|
||||
|
||||
Console.WriteLine($"# User: '{Task}'");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
await foreach (var response in agent.RunStreamingAsync(Task, thread))
|
||||
{
|
||||
Console.Write(response.Text);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
This sample demonstrates how to create an Azure AI Agent with the Deep Research Tool, which leverages the o3-deep-research reasoning model to perform comprehensive research on complex topics.
|
||||
|
||||
Key features:
|
||||
- Configuring and using the Deep Research Tool with Bing grounding
|
||||
- Creating a persistent AI agent with deep research capabilities
|
||||
- Executing deep research queries and retrieving results
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before running this sample, ensure you have:
|
||||
|
||||
1. An Azure AI Foundry project set up
|
||||
2. A deep research model deployment (e.g., o3-deep-research)
|
||||
3. A model deployment (e.g., gpt-4o)
|
||||
4. A Bing Connection configured in your Azure AI Foundry project
|
||||
5. Azure CLI installed and authenticated
|
||||
|
||||
**Important**: Please visit the following documentation for detailed setup instructions:
|
||||
- [Deep Research Tool Documentation](https://aka.ms/agents-deep-research)
|
||||
- [Research Tool Setup](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/deep-research#research-tool-setup)
|
||||
|
||||
Pay special attention to the purple `Note` boxes in the Azure documentation.
|
||||
|
||||
**Note**: The Bing Connection ID must be from the **project**, not the resource. It has the following format:
|
||||
|
||||
```
|
||||
/subscriptions/<sub_id>/resourceGroups/<rg_name>/providers/<provider_name>/accounts/<account_name>/projects/<project_name>/connections/<connection_name>
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
# Replace with your Azure AI Foundry project endpoint
|
||||
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/"
|
||||
|
||||
# Replace with your Bing connection ID from the project
|
||||
$env:BING_CONNECTION_ID="/subscriptions/.../connections/your-bing-connection"
|
||||
|
||||
# Optional, defaults to o3-deep-research
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME="o3-deep-research"
|
||||
|
||||
# Optional, defaults to gpt-4o
|
||||
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o"
|
||||
@@ -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.|
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use background responses with ChatClientAgent and OpenAI Responses.
|
||||
// This sample shows how to use background responses with ChatClientAgent and Azure OpenAI Responses.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# What This Sample Shows
|
||||
|
||||
This sample demonstrates how to use background responses with ChatCompletionAgent and OpenAI Responses for long-running operations. Background responses support:
|
||||
This sample demonstrates how to use background responses with ChatCompletionAgent and Azure OpenAI Responses for long-running operations. Background responses support:
|
||||
|
||||
- **Polling for completion** - Non-streaming APIs can start a background operation and return a continuation token. Poll with the token until the response completes.
|
||||
- **Resuming after interruption** - Streaming APIs can be interrupted and resumed from the last update using the continuation token.
|
||||
@@ -14,7 +14,10 @@ For more information, see the [official documentation](https://learn.microsoft.c
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 8.0 SDK or later
|
||||
- OpenAI api key
|
||||
- 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:
|
||||
|
||||
|
||||
+21
@@ -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,84 @@
|
||||
// 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 = 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();
|
||||
|
||||
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);
|
||||
}
|
||||
+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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mem0\Microsoft.Agents.AI.Mem0.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use the Mem0Provider to persist and recall memories for an agent.
|
||||
// The sample stores conversation messages in a Mem0 service and retrieves relevant memories
|
||||
// for subsequent invocations, even across new threads.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Mem0;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
var mem0ServiceUri = Environment.GetEnvironmentVariable("MEM0_ENDPOINT") ?? throw new InvalidOperationException("MEM0_ENDPOINT is not set.");
|
||||
var mem0ApiKey = Environment.GetEnvironmentVariable("MEM0_APIKEY") ?? throw new InvalidOperationException("MEM0_APIKEY is not set.");
|
||||
|
||||
// Create an HttpClient for Mem0 with the required base address and authentication.
|
||||
using HttpClient mem0HttpClient = new();
|
||||
mem0HttpClient.BaseAddress = new Uri(mem0ServiceUri);
|
||||
mem0HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", mem0ApiKey);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.CreateAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.",
|
||||
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined
|
||||
// If each thread should have its own Mem0 scope, you can create a new id per thread here:
|
||||
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() })
|
||||
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
|
||||
? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
|
||||
// For cases where we are restoring from serialized state:
|
||||
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions)
|
||||
});
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
// Clear any existing memories for this scope to demonstrate fresh behavior.
|
||||
Mem0Provider mem0Provider = thread.GetService<Mem0Provider>()!;
|
||||
await mem0Provider.ClearStoredMemoriesAsync();
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", thread));
|
||||
Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", thread));
|
||||
|
||||
Console.WriteLine("\nWaiting briefly for Mem0 to index the new memories...\n");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", thread));
|
||||
|
||||
Console.WriteLine("\n>> Serialize and deserialize the thread to demonstrate persisted state\n");
|
||||
JsonElement serializedThread = thread.Serialize();
|
||||
AgentThread restoredThread = agent.DeserializeThread(serializedThread);
|
||||
Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredThread));
|
||||
|
||||
Console.WriteLine("\n>> Start a new thread that shares the same Mem0 scope\n");
|
||||
AgentThread newThread = agent.GetNewThread();
|
||||
Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newThread));
|
||||
+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>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use background responses with ChatClientAgent and Azure OpenAI Responses for long-running operations.
|
||||
// It shows polling for completion using continuation tokens, function calling during background operations,
|
||||
// and persisting/restoring agent state between polling cycles.
|
||||
|
||||
#pragma warning disable CA1050 // Declare types in namespaces
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
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-5";
|
||||
|
||||
var stateStore = new Dictionary<string, JsonElement?>();
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
name: "SpaceNovelWriter",
|
||||
instructions: "You are a space novel writer. Always research relevant facts and generate character profiles for the main characters before writing novels." +
|
||||
"Write complete chapters without asking for approval or feedback. Do not ask the user about tone, style, pace, or format preferences - just write the novel based on the request.",
|
||||
tools: [AIFunctionFactory.Create(ResearchSpaceFactsAsync), AIFunctionFactory.Create(GenerateCharacterProfilesAsync)]);
|
||||
|
||||
// Enable background responses (only supported by {Azure}OpenAI Responses at this time).
|
||||
AgentRunOptions options = new() { AllowBackgroundResponses = true };
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
|
||||
// Start the initial run.
|
||||
AgentRunResponse response = await agent.RunAsync("Write a very long novel about a team of astronauts exploring an uncharted galaxy.", thread, options);
|
||||
|
||||
// Poll for background responses until complete.
|
||||
while (response.ContinuationToken is not null)
|
||||
{
|
||||
PersistAgentState(thread, response.ContinuationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
|
||||
RestoreAgentState(agent, out thread, out object? continuationToken);
|
||||
|
||||
options.ContinuationToken = continuationToken;
|
||||
response = await agent.RunAsync(thread, options);
|
||||
}
|
||||
|
||||
Console.WriteLine(response.Text);
|
||||
|
||||
void PersistAgentState(AgentThread thread, object? continuationToken)
|
||||
{
|
||||
stateStore["thread"] = thread.Serialize();
|
||||
stateStore["continuationToken"] = JsonSerializer.SerializeToElement(continuationToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
}
|
||||
|
||||
void RestoreAgentState(AIAgent agent, out AgentThread thread, out object? continuationToken)
|
||||
{
|
||||
JsonElement serializedThread = stateStore["thread"] ?? throw new InvalidOperationException("No serialized thread found in state store.");
|
||||
JsonElement? serializedToken = stateStore["continuationToken"];
|
||||
|
||||
thread = agent.DeserializeThread(serializedThread);
|
||||
continuationToken = serializedToken?.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
}
|
||||
|
||||
[Description("Researches relevant space facts and scientific information for writing a science fiction novel")]
|
||||
async Task<string> ResearchSpaceFactsAsync(string topic)
|
||||
{
|
||||
Console.WriteLine($"[ResearchSpaceFacts] Researching topic: {topic}");
|
||||
|
||||
// Simulate a research operation
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
|
||||
string result = topic.ToUpperInvariant() switch
|
||||
{
|
||||
var t when t.Contains("GALAXY") => "Research findings: Galaxies contain billions of stars. Uncharted galaxies may have unique stellar formations, exotic matter, and unexplored phenomena like dark energy concentrations.",
|
||||
var t when t.Contains("SPACE") || t.Contains("TRAVEL") => "Research findings: Interstellar travel requires advanced propulsion systems. Challenges include radiation exposure, life support, and navigation through unknown space.",
|
||||
var t when t.Contains("ASTRONAUT") => "Research findings: Astronauts undergo rigorous training in zero-gravity environments, emergency protocols, spacecraft systems, and team dynamics for long-duration missions.",
|
||||
_ => $"Research findings: General space exploration facts related to {topic}. Deep space missions require advanced technology, crew resilience, and contingency planning for unknown scenarios."
|
||||
};
|
||||
|
||||
Console.WriteLine("[ResearchSpaceFacts] Research complete");
|
||||
return result;
|
||||
}
|
||||
|
||||
[Description("Generates character profiles for the main astronaut characters in the novel")]
|
||||
async Task<IEnumerable<string>> GenerateCharacterProfilesAsync()
|
||||
{
|
||||
Console.WriteLine("[GenerateCharacterProfiles] Generating character profiles...");
|
||||
|
||||
// Simulate a character generation operation
|
||||
await Task.Delay(TimeSpan.FromSeconds(10));
|
||||
|
||||
string[] profiles = [
|
||||
"Captain Elena Voss: A seasoned mission commander with 15 years of experience. Strong-willed and decisive, she struggles with the weight of responsibility for her crew. Former military pilot turned astronaut.",
|
||||
"Dr. James Chen: Chief science officer and astrophysicist. Brilliant but socially awkward, he finds solace in data and discovery. His curiosity often pushes the mission into uncharted territory.",
|
||||
"Lieutenant Maya Torres: Navigation specialist and youngest crew member. Optimistic and tech-savvy, she brings fresh perspective and innovative problem-solving to challenges.",
|
||||
"Commander Marcus Rivera: Chief engineer with expertise in spacecraft systems. Pragmatic and resourceful, he can fix almost anything with limited resources. Values crew safety above all.",
|
||||
"Dr. Amara Okafor: Medical officer and psychologist. Empathetic and observant, she helps maintain crew morale and mental health during the long journey. Expert in space medicine."
|
||||
];
|
||||
|
||||
Console.WriteLine($"[GenerateCharacterProfiles] Generated {profiles.Length} character profiles");
|
||||
return profiles;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# What This Sample Shows
|
||||
|
||||
This sample demonstrates how to use background responses with ChatCompletionAgent and Azure OpenAI Responses for long-running operations. Background responses support:
|
||||
|
||||
- **Polling for completion** - Non-streaming APIs can start a background operation and return a continuation token. Poll with the token until the response completes.
|
||||
- **Function calling** - Functions can be called during background operations.
|
||||
- **State persistence** - Thread and continuation token can be persisted and restored between polling cycles.
|
||||
|
||||
> **Note:** Background responses are currently only supported by OpenAI Responses.
|
||||
|
||||
For more information, see the [official documentation](https://learn.microsoft.com/en-us/agent-framework/user-guide/agents/agent-background-responses?pivots=programming-language-csharp).
|
||||
|
||||
# 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-5" # Optional, defaults to gpt-5
|
||||
```
|
||||
@@ -43,6 +43,9 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent|
|
||||
|[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally|
|
||||
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|
||||
|[Adding RAG with text search](./Agent_Step18_TextSearchRag/)|This sample demonstrates how to enrich agent responses with retrieval augmented generation using the text search provider|
|
||||
|[Using Mem0-backed memory](./Agent_Step19_Mem0Provider/)|This sample demonstrates how to use the Mem0Provider to persist and recall memories across conversations|
|
||||
|[Background responses with tools and persistence](./Agent_Step20_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+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)
|
||||
{
|
||||
|
||||
@@ -184,6 +184,7 @@ internal sealed class Program
|
||||
|
||||
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
|
||||
{
|
||||
// Always dispose the run when done.
|
||||
await using IAsyncDisposable disposeRun = run;
|
||||
|
||||
bool hasStreamed = false;
|
||||
@@ -231,7 +232,7 @@ internal sealed class Program
|
||||
}
|
||||
else
|
||||
{
|
||||
await run.Run.DisposeAsync();
|
||||
// Yield to handle the external request
|
||||
return requestInfo.Request;
|
||||
}
|
||||
break;
|
||||
@@ -326,7 +327,7 @@ internal sealed class Program
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
return null; // No request to handle
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Azure.Monitor.OpenTelemetry.Exporter;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample shows how to enable OpenTelemetry observability for workflows when
|
||||
/// using them as <see cref="AIAgent"/>s.
|
||||
///
|
||||
/// In this example, we create a workflow that uses two language agents to process
|
||||
/// input concurrently, one that responds in French and another that responds in English.
|
||||
///
|
||||
/// You will interact with the workflow in an interactive loop, sending messages and receiving
|
||||
/// streaming responses from the workflow as if it were an agent who responds in both languages.
|
||||
///
|
||||
/// OpenTelemetry observability is enabled at multiple levels:
|
||||
/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service.
|
||||
/// 2. At the agent level, capturing telemetry for agent operations.
|
||||
/// 3. At the workflow level, capturing telemetry for workflow execution.
|
||||
///
|
||||
/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to
|
||||
/// Azure Monitor if an Application Insights connection string is provided.
|
||||
///
|
||||
/// Learn how to set up an Aspire dashboard here:
|
||||
/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pre-requisites:
|
||||
/// - Foundational samples should be completed first.
|
||||
/// - This sample uses concurrent processing.
|
||||
/// - An Azure OpenAI endpoint and deployment name.
|
||||
/// - An Application Insights resource for telemetry (optional).
|
||||
/// </remarks>
|
||||
public static class Program
|
||||
{
|
||||
private const string SourceName = "Workflow.ApplicationInsightsSample";
|
||||
private static readonly ActivitySource s_activitySource = new(SourceName);
|
||||
|
||||
private static async Task Main()
|
||||
{
|
||||
// Set up observability
|
||||
var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
|
||||
var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317";
|
||||
|
||||
var resourceBuilder = ResourceBuilder
|
||||
.CreateDefault()
|
||||
.AddService("WorkflowSample");
|
||||
|
||||
var traceProviderBuilder = Sdk.CreateTracerProviderBuilder()
|
||||
.SetResourceBuilder(resourceBuilder)
|
||||
.AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry
|
||||
.AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry
|
||||
.AddSource(SourceName);
|
||||
|
||||
traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
|
||||
if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString))
|
||||
{
|
||||
traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString);
|
||||
}
|
||||
|
||||
using var traceProvider = traceProviderBuilder.Build();
|
||||
|
||||
// Set up the Azure OpenAI client
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level
|
||||
.Build();
|
||||
|
||||
// Start a root activity for the application
|
||||
using var activity = s_activitySource.StartActivity("main");
|
||||
Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}");
|
||||
|
||||
// Create the workflow and turn it into an agent with OpenTelemetry instrumentation
|
||||
var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName);
|
||||
var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName)
|
||||
{
|
||||
EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses
|
||||
};
|
||||
var thread = agent.GetNewThread();
|
||||
|
||||
// Start an interactive loop to interact with the workflow as if it were an agent
|
||||
while (true)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.Write("User (or 'exit' to quit): ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessInputAsync(agent, thread, input);
|
||||
}
|
||||
|
||||
// Helper method to process user input and display streaming responses. To display
|
||||
// multiple interleaved responses correctly, we buffer updates by message ID and
|
||||
// re-render all messages on each update.
|
||||
static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input)
|
||||
{
|
||||
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
|
||||
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
|
||||
{
|
||||
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
|
||||
{
|
||||
// skip updates that don't have a message ID or text
|
||||
continue;
|
||||
}
|
||||
Console.Clear();
|
||||
|
||||
if (!buffer.TryGetValue(update.MessageId, out List<AgentRunResponseUpdate>? value))
|
||||
{
|
||||
value = [];
|
||||
buffer[update.MessageId] = value;
|
||||
}
|
||||
value.Add(update);
|
||||
|
||||
foreach (var (messageId, segments) in buffer)
|
||||
{
|
||||
string combinedText = string.Concat(segments);
|
||||
Console.WriteLine($"{segments[0].AuthorName}: {combinedText}");
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="OpenTelemetry" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace WorkflowAsAnAgentObservabilitySample;
|
||||
|
||||
internal static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a workflow that uses two language agents to process input concurrently.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client to use for the agents</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>A workflow that processes input using two language agents</returns>
|
||||
internal static Workflow GetWorkflow(IChatClient chatClient, string sourceName)
|
||||
{
|
||||
// Create executors
|
||||
var startExecutor = new ConcurrentStartExecutor();
|
||||
var aggregationExecutor = new ConcurrentAggregationExecutor();
|
||||
AIAgent frenchAgent = GetLanguageAgent("French", chatClient, sourceName);
|
||||
AIAgent englishAgent = GetLanguageAgent("English", chatClient, sourceName);
|
||||
|
||||
// Build the workflow by adding executors and connecting them
|
||||
return new WorkflowBuilder(startExecutor)
|
||||
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
|
||||
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
|
||||
.WithOutputFrom(aggregationExecutor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a language agent for the specified target language.
|
||||
/// </summary>
|
||||
/// <param name="targetLanguage">The target language for translation</param>
|
||||
/// <param name="chatClient">The chat client to use for the agent</param>
|
||||
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
|
||||
/// <returns>An AIAgent configured for the specified language</returns>
|
||||
private static AIAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient, string sourceName) =>
|
||||
new ChatClientAgent(
|
||||
chatClient,
|
||||
instructions: $"You're a helpful assistant who always responds in {targetLanguage}.",
|
||||
name: $"{targetLanguage}Agent"
|
||||
)
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
|
||||
.Build();
|
||||
|
||||
/// <summary>
|
||||
/// Executor that starts the concurrent processing by sending messages to the agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
|
||||
{
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
return routeBuilder
|
||||
.AddHandler<List<ChatMessage>>(this.RouteMessages)
|
||||
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
|
||||
}
|
||||
|
||||
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that aggregates the results from the concurrent agents.
|
||||
/// </summary>
|
||||
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
|
||||
{
|
||||
private readonly List<ChatMessage> _messages = [];
|
||||
|
||||
/// <summary>
|
||||
/// Handles incoming messages from the agents and aggregates their responses.
|
||||
/// </summary>
|
||||
/// <param name="message">The message from the agent</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._messages.AddRange(message);
|
||||
|
||||
if (this._messages.Count == 2)
|
||||
{
|
||||
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}"));
|
||||
await context.YieldOutputAsync(formattedMessages, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-18
@@ -20,7 +20,9 @@ public static class Program
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
UppercaseExecutor uppercase = new();
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
@@ -40,23 +42,6 @@ public static class Program
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First executor: converts input text to uppercase.
|
||||
/// </summary>
|
||||
internal sealed class UppercaseExecutor() : Executor<string, string>("UppercaseExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by converting it to uppercase.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to convert</param>
|
||||
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
|
||||
/// The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>The input text converted to uppercase</returns>
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
|
||||
ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class Program
|
||||
.Build();
|
||||
|
||||
// Step 2: Configure the sub-workflow as an executor for use in the parent workflow
|
||||
ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow");
|
||||
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("TextProcessingSubWorkflow");
|
||||
|
||||
// Step 3: Build a main workflow that uses the sub-workflow as an executor
|
||||
Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n");
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content.
|
||||
|
||||
## What You'll Learn
|
||||
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally
|
||||
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally
|
||||
2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions
|
||||
3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses
|
||||
4. **Building complex pipelines** - Chaining multiple heterogeneous components together
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -28,7 +27,7 @@ public static class A2AAgentCardExtensions
|
||||
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
|
||||
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
|
||||
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
|
||||
public static async Task<AIAgent> GetAIAgentAsync(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
|
||||
public static AIAgent GetAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
// Create the A2A client using the agent URL from the card.
|
||||
var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
|
||||
|
||||
@@ -42,6 +42,6 @@ public static class A2ACardResolverExtensions
|
||||
// Obtain the agent card from the resolver.
|
||||
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return await agentCard.GetAIAgentAsync(httpClient, loggerFactory).ConfigureAwait(false);
|
||||
return agentCard.GetAIAgent(httpClient, loggerFactory);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="AIContext"/> serves as a container for contextual information that <see cref="AIContextProvider"/> instances
|
||||
/// can supply to enhance AI model interactions. This context is combined across multiple providers and merged with
|
||||
/// can supply to enhance AI model interactions. This context is merged with
|
||||
/// the agent's base configuration before being passed to the underlying AI model.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Context information is transient by default and applies only to the current invocation, though messages
|
||||
/// Context information is transient by default and applies only to the current invocation, however messages
|
||||
/// added through the <see cref="Messages"/> property will be permanently incorporated into the conversation history.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
|
||||
@@ -18,25 +18,25 @@ namespace Microsoft.Agents.AI;
|
||||
/// An AI context provider is a component that participates in the agent invocation lifecycle by:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Listening to changes in conversations</description></item>
|
||||
/// <item><description>Providing additional context to AI models or agents before invocation</description></item>
|
||||
/// <item><description>Providing additional context to agents during invocation</description></item>
|
||||
/// <item><description>Supplying additional function tools for enhanced capabilities</description></item>
|
||||
/// <item><description>Processing invocation results for state management or learning</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Context providers operate through a two-phase lifecycle: they are called before invocation via
|
||||
/// <see cref="InvokingAsync"/> to provide context, and optionally called after invocation via
|
||||
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
|
||||
/// <see cref="InvokingAsync"/> to provide context, and optionally called at the end of invocation via
|
||||
/// <see cref="InvokedAsync"/> to process results.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class AIContextProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Called immediately before an AI model or agent is invoked to provide additional context.
|
||||
/// Called at the start of agent invocation to provide additional context.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the request context including the messages that will be sent to the AI model or agent.</param>
|
||||
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</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 represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be provided to the AI model or agent.</returns>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be used by the agent during this invocation.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implementers can load any additional context required at this time, such as:
|
||||
@@ -47,14 +47,11 @@ public abstract class AIContextProvider
|
||||
/// <item><description>Injecting contextual messages from conversation history</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The returned context will be combined with context from other providers before being passed to the AI model or agent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Called immediately after an AI model or agent has been invoked to process the results.
|
||||
/// Called at the end of the agent invocation to process the invocation results.
|
||||
/// </summary>
|
||||
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
@@ -123,8 +120,8 @@ public abstract class AIContextProvider
|
||||
/// Contains the context information provided to <see cref="InvokingAsync(InvokingContext, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class provides context about the upcoming AI model or agent invocation, including the messages
|
||||
/// that will be sent. Context providers can use this information to determine what additional context
|
||||
/// This class provides context about the invocation before the underlying AI model is invoked, including the messages
|
||||
/// that will be used. Context providers can use this information to determine what additional context
|
||||
/// should be provided for the invocation.
|
||||
/// </remarks>
|
||||
public class InvokingContext
|
||||
@@ -132,7 +129,7 @@ public abstract class AIContextProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="requestMessages">The messages to be sent to the AI model or agent for this invocation.</param>
|
||||
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
|
||||
{
|
||||
@@ -140,11 +137,10 @@ public abstract class AIContextProvider
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that will be sent to the AI model or agent for this invocation.
|
||||
/// Gets the caller provided messages that will be used by the agent for this invocation.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing the conversation history
|
||||
/// and new messages that will be processed by the AI model or agent.
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
|
||||
/// </value>
|
||||
public IEnumerable<ChatMessage> RequestMessages { get; }
|
||||
}
|
||||
@@ -153,8 +149,8 @@ public abstract class AIContextProvider
|
||||
/// Contains the context information provided to <see cref="InvokedAsync(InvokedContext, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This class provides context about a completed AI model or agent invocation, including both the
|
||||
/// request messages that were sent and the response messages that were generated. It also indicates
|
||||
/// This class provides context about a completed agent invocation, including both the
|
||||
/// request messages that were used and the response messages that were generated. It also indicates
|
||||
/// whether the invocation succeeded or failed.
|
||||
/// </remarks>
|
||||
public class InvokedContext
|
||||
@@ -162,30 +158,41 @@ public abstract class AIContextProvider
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
|
||||
/// </summary>
|
||||
/// <param name="requestMessages">The messages that were sent to the AI model or agent for this invocation.</param>
|
||||
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
|
||||
/// <param name="aiContextProviderMessages">The messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
|
||||
public InvokedContext(IEnumerable<ChatMessage> requestMessages)
|
||||
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage>? aiContextProviderMessages)
|
||||
{
|
||||
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
|
||||
this.AIContextProviderMessages = aiContextProviderMessages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that were sent to the AI model or agent for this invocation.
|
||||
/// Gets the caller provided messages that were used by the agent for this invocation.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing the conversation history
|
||||
/// and new messages that were processed by the AI model or agent.
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
|
||||
/// This does not include any <see cref="AIContextProvider"/> supplied messages.
|
||||
/// </value>
|
||||
public IEnumerable<ChatMessage> RequestMessages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of response messages generated by the AI model or agent if the invocation succeeded.
|
||||
/// Gets the messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing the response from the AI model or agent,
|
||||
/// A collection of <see cref="ChatMessage"/> instances that were provided by the <see cref="AIContextProvider"/>,
|
||||
/// and were used by the agent as part of the invocation.
|
||||
/// </value>
|
||||
public IEnumerable<ChatMessage>? AIContextProviderMessages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// A collection of <see cref="ChatMessage"/> instances representing the response,
|
||||
/// or <see langword="null"/> if the invocation failed or did not produce response messages.
|
||||
/// </value>
|
||||
public IEnumerable<ChatMessage>? ResponseMessages { get; init; }
|
||||
public IEnumerable<ChatMessage>? ResponseMessages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
|
||||
@@ -193,6 +200,6 @@ public abstract class AIContextProvider
|
||||
/// <value>
|
||||
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
|
||||
/// </value>
|
||||
public Exception? InvokeException { get; init; }
|
||||
public Exception? InvokeException { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,42 +131,86 @@ public sealed class Mem0Provider : AIContextProvider
|
||||
Environment.NewLine,
|
||||
context.RequestMessages.Where(m => !string.IsNullOrWhiteSpace(m.Text)).Select(m => m.Text));
|
||||
|
||||
var memories = (await this._client.SearchAsync(
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId,
|
||||
queryText,
|
||||
cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var contextInstructions = memories.Count == 0
|
||||
? null
|
||||
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
|
||||
|
||||
if (this._logger is not null)
|
||||
try
|
||||
{
|
||||
this._logger.LogInformation("Mem0AIContextProvider retrieved {Count} memories.", memories.Count);
|
||||
if (contextInstructions is not null)
|
||||
var memories = (await this._client.SearchAsync(
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId,
|
||||
queryText,
|
||||
cancellationToken).ConfigureAwait(false)).ToList();
|
||||
|
||||
var outputMessageText = memories.Count == 0
|
||||
? null
|
||||
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
|
||||
|
||||
if (this._logger is not null)
|
||||
{
|
||||
this._logger.LogTrace("Mem0AIContextProvider instructions: {Instructions}", contextInstructions);
|
||||
this._logger.LogInformation(
|
||||
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
memories.Count,
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
if (outputMessageText is not null)
|
||||
{
|
||||
this._logger.LogTrace(
|
||||
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
queryText,
|
||||
outputMessageText,
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new AIContext
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
|
||||
};
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, contextInstructions)]
|
||||
};
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
return new AIContext();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Persist request and response messages after invocation.
|
||||
await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (context.ResponseMessages is not null)
|
||||
if (context.InvokeException is not null)
|
||||
{
|
||||
await this.PersistMessagesAsync(context.ResponseMessages, cancellationToken).ConfigureAwait(false);
|
||||
return; // Do not update memory on failed invocations.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Persist request and response messages after invocation.
|
||||
await this.PersistMessagesAsync(context.RequestMessages.Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(
|
||||
ex,
|
||||
"Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
|
||||
this.ApplicationId,
|
||||
this.AgentId,
|
||||
this.ThreadId,
|
||||
this.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<!-- Disable packing until we are ready to release this as a nuget -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -14,6 +13,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
|
||||
+3
-3
@@ -19,12 +19,12 @@ internal sealed class WorkflowModelBuilder : IModelBuilder<Func<object?, bool>>
|
||||
Debug.WriteLine($"> CONNECT: {source.Id} => {target.Id}{(condition is null ? string.Empty : " (?)")}");
|
||||
|
||||
this.WorkflowBuilder.AddEdge(
|
||||
GetExecutorIsh(source),
|
||||
GetExecutorIsh(target),
|
||||
GetExecutorBinding(source),
|
||||
GetExecutorBinding(target),
|
||||
condition);
|
||||
}
|
||||
|
||||
private static ExecutorIsh GetExecutorIsh(IModeledAction action) =>
|
||||
private static ExecutorBinding GetExecutorBinding(IModeledAction action) =>
|
||||
action switch
|
||||
{
|
||||
RequestPortAction port => port.RequestPort,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
|
||||
/// </summary>
|
||||
/// <param name="Agent">The AI agent.</param>
|
||||
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
|
||||
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
|
||||
: ExecutorBinding(Throw.IfNull(Agent).Name ?? Throw.IfNull(Agent.Id),
|
||||
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
|
||||
typeof(AIAgentHostExecutor),
|
||||
Agent)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public static partial class AgentWorkflowBuilder
|
||||
// Create a builder that chains the agents together in sequence. The workflow simply begins
|
||||
// with the first agent in the sequence.
|
||||
WorkflowBuilder? builder = null;
|
||||
ExecutorIsh? previous = null;
|
||||
ExecutorBinding? previous = null;
|
||||
foreach (var agent in agents)
|
||||
{
|
||||
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
|
||||
@@ -125,8 +125,8 @@ public static partial class AgentWorkflowBuilder
|
||||
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
|
||||
// accumulator would not be able to determine what came from what agent, as there's currently no
|
||||
// provenance tracking exposed in the workflow context passed to a handler.
|
||||
ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
|
||||
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
|
||||
builder.AddFanOutEdge(start, targets: agentExecutors);
|
||||
for (int i = 0; i < agentExecutors.Length; i++)
|
||||
{
|
||||
@@ -141,7 +141,7 @@ public static partial class AgentWorkflowBuilder
|
||||
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
|
||||
(string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
|
||||
|
||||
ExecutorIsh end = endFactory.ConfigureFactory(ConcurrentEndExecutor.ExecutorId);
|
||||
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
|
||||
|
||||
builder.AddFanInEdge(end, sources: accumulators);
|
||||
|
||||
|
||||
@@ -8,23 +8,40 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal class ChatProtocolExecutorOptions
|
||||
/// <summary>
|
||||
/// Provides configuration options for <see cref="ChatProtocolExecutor"/>.
|
||||
/// </summary>
|
||||
public class ChatProtocolExecutorOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the chat role to use when converting string messages to <see cref="ChatMessage"/> instances.
|
||||
/// If set, the executor will accept string messages and convert them to chat messages with this role.
|
||||
/// </summary>
|
||||
public ChatRole? StringMessageChatRole { get; set; }
|
||||
}
|
||||
|
||||
// TODO: Make this a public type (in a later PR; todo: make an issue)
|
||||
internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
/// <summary>
|
||||
/// Provides a base class for executors that implement the Agent Workflow Chat Protocol.
|
||||
/// This executor maintains a list of chat messages and processes them when a turn is taken.
|
||||
/// </summary>
|
||||
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
|
||||
{
|
||||
private readonly static Func<List<ChatMessage>> s_initFunction = () => [];
|
||||
private readonly ChatRole? _stringMessageChatRole;
|
||||
|
||||
internal ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatProtocolExecutor"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for this executor instance. Cannot be null or empty.</param>
|
||||
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
|
||||
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
|
||||
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
|
||||
: base(id, () => [], declareCrossRunShareable: declareCrossRunShareable)
|
||||
{
|
||||
this._stringMessageChatRole = options?.StringMessageChatRole;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
if (this._stringMessageChatRole.HasValue)
|
||||
@@ -40,6 +57,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
|
||||
.AddHandler<TurnToken>(this.TakeTurnAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single chat message to the accumulated messages for the current turn.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to add.</param>
|
||||
/// <param name="context">The workflow context in which the executor executes.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
protected ValueTask AddMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
|
||||
@@ -52,6 +76,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds multiple chat messages to the accumulated messages for the current turn.
|
||||
/// </summary>
|
||||
/// <param name="messages">The collection of chat messages to add.</param>
|
||||
/// <param name="context">The workflow context in which the executor executes.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
protected ValueTask AddMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
|
||||
@@ -64,6 +95,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a turn token by processing all accumulated chat messages and then resetting the message state.
|
||||
/// </summary>
|
||||
/// <param name="token">The turn token that triggers message processing.</param>
|
||||
/// <param name="context">The workflow context in which the executor executes.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return this.InvokeWithStateAsync(InvokeTakeTurnAsync, context, cancellationToken: cancellationToken);
|
||||
@@ -81,5 +119,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
|
||||
/// </summary>
|
||||
/// <param name="messages">The list of chat messages accumulated since the last turn.</param>
|
||||
/// <param name="context">The workflow context in which the executor executes.</param>
|
||||
/// <param name="emitEvents">Indicates whether events should be emitted during processing. If null, the default behavior is used.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
protected abstract ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ internal sealed record class ExecutorInfo(TypeId ExecutorType, string ExecutorId
|
||||
this.ExecutorType.IsMatch(executor.GetType())
|
||||
&& this.ExecutorId == executor.Id;
|
||||
|
||||
public bool IsMatch(ExecutorRegistration registration) =>
|
||||
this.ExecutorType.IsMatch(registration.ExecutorType)
|
||||
&& this.ExecutorId == registration.Id;
|
||||
public bool IsMatch(ExecutorBinding binding) =>
|
||||
this.ExecutorType.IsMatch(binding.ExecutorType)
|
||||
&& this.ExecutorId == binding.Id;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
internal static class RepresentationExtensions
|
||||
{
|
||||
public static ExecutorInfo ToExecutorInfo(this ExecutorRegistration registration)
|
||||
public static ExecutorInfo ToExecutorInfo(this ExecutorBinding binding)
|
||||
{
|
||||
Throw.IfNull(registration);
|
||||
return new ExecutorInfo(new TypeId(registration.ExecutorType), registration.Id);
|
||||
Throw.IfNull(binding);
|
||||
return new ExecutorInfo(new TypeId(binding.ExecutorType), binding.Id);
|
||||
}
|
||||
|
||||
public static EdgeInfo ToEdgeInfo(this Edge edge)
|
||||
@@ -38,8 +38,8 @@ internal static class RepresentationExtensions
|
||||
Throw.IfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorInfo> executors =
|
||||
workflow.Registrations.Values.ToDictionary(
|
||||
keySelector: registration => registration.Id,
|
||||
workflow.ExecutorBindings.Values.ToDictionary(
|
||||
keySelector: binding => binding.Id,
|
||||
elementSelector: ToExecutorInfo);
|
||||
|
||||
Dictionary<string, List<EdgeInfo>> edges = workflow.Edges.Keys.ToDictionary(
|
||||
|
||||
@@ -47,10 +47,10 @@ internal sealed class WorkflowInfo
|
||||
}
|
||||
|
||||
// Validate the executors
|
||||
if (workflow.Registrations.Count != this.Executors.Count ||
|
||||
if (workflow.ExecutorBindings.Count != this.Executors.Count ||
|
||||
this.Executors.Keys.Any(
|
||||
executorId => workflow.Registrations.TryGetValue(executorId, out ExecutorRegistration? registration)
|
||||
&& !this.Executors[executorId].IsMatch(registration)))
|
||||
executorId => workflow.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding)
|
||||
&& !this.Executors[executorId].IsMatch(binding)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
// TODO: Unwrap the Configured object, just like for SubworkflowBinding
|
||||
internal record ConfiguredExecutorBinding(Configured<Executor> ConfiguredExecutor, Type ExecutorType)
|
||||
: ExecutorBinding(Throw.IfNull(ConfiguredExecutor).Id,
|
||||
ConfiguredExecutor.BoundFactoryAsync,
|
||||
ExecutorType,
|
||||
ConfiguredExecutor.Raw)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance { get; } = ConfiguredExecutor.Raw is Executor;
|
||||
|
||||
protected override async ValueTask<bool> ResetCoreAsync()
|
||||
{
|
||||
if (this.ConfiguredExecutor.Raw is IResettableExecutor resettable)
|
||||
{
|
||||
await resettable.ResetAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// A custom IAsyncEnumerable implementation that reads from a ChannelReader,
|
||||
/// and suppresses OperationCanceledException when the cancellation token is triggered.
|
||||
/// </summary>
|
||||
internal sealed class NonThrowingChannelReaderAsyncEnumerable<T>(ChannelReader<T> reader) : IAsyncEnumerable<T>
|
||||
{
|
||||
private class Enumerator(ChannelReader<T> reader, CancellationToken cancellationToken) : IAsyncEnumerator<T>
|
||||
{
|
||||
private T? _current;
|
||||
public T Current => this._current ?? throw new InvalidOperationException("Enumeration not started.");
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
// no-op - the reader should not be disposed.
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves to the next item in the channel.
|
||||
/// </summary>
|
||||
/// <returns>If successful, returns <c>true</c>, otherwise <c>false</c>.</returns>
|
||||
public async ValueTask<bool> MoveNextAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
bool hasData = await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (hasData)
|
||||
{
|
||||
this._current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Swallow cancellation exceptions to prevent throwing from the enumerator
|
||||
// Enables clean cancellation and aligns with the expected behavior of IAsyncEnumerable.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an async enumerator that reads items from the channel.
|
||||
/// If cancellation is requested, the enumeration exits silently without throwing.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">An optional cancellation token from the caller.</param>
|
||||
/// <returns>An async enumerator over the channel items.</returns>
|
||||
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
|
||||
=> new Enumerator(reader, cancellationToken);
|
||||
}
|
||||
@@ -139,11 +139,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Get the current epoch - we'll only respond to completion signals from this epoch or later
|
||||
int myEpoch = Volatile.Read(ref this._completionEpoch) + 1;
|
||||
|
||||
// Simply read from channel - all coordination is handled by Channel infrastructure
|
||||
// Note: When cancellation is requested, ReadAllAsync may throw OperationCanceledException
|
||||
// or may complete the enumeration. We check IsCancellationRequested explicitly at superstep
|
||||
// boundaries to ensure clean cancellation.
|
||||
await foreach (WorkflowEvent evt in this._eventChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
// Use custom async enumerable to avoid exceptions on cancellation.
|
||||
NonThrowingChannelReaderAsyncEnumerable<WorkflowEvent> eventStream = new(this._eventChannel.Reader);
|
||||
await foreach (WorkflowEvent evt in eventStream.WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Filter out internal signals used for run loop coordination
|
||||
if (evt is InternalHaltSignal completionSignal)
|
||||
|
||||
@@ -27,6 +27,8 @@ public abstract class Executor : IIdentified
|
||||
private static readonly string s_namespace = typeof(Executor).Namespace!;
|
||||
private static readonly ActivitySource s_activitySource = new(s_namespace);
|
||||
|
||||
// TODO: Add overloads for binding with a configuration/options object once the Configured<T> hierarchy goes away.
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the executor with a unique identifier
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the binding information for a workflow executor, including its identifier, factory method, type, and
|
||||
/// optional raw value.
|
||||
/// </summary>
|
||||
/// <param name="Id">The unique identifier for the executor in the workflow.</param>
|
||||
/// <param name="FactoryAsync">A factory function that creates an instance of the executor. The function accepts two string parameters and returns
|
||||
/// a ValueTask containing the created Executor instance.</param>
|
||||
/// <param name="ExecutorType">The type of the executor. Must be a type derived from Executor.</param>
|
||||
/// <param name="RawValue">An optional raw value associated with the binding.</param>
|
||||
public abstract record class ExecutorBinding(string Id, Func<string, ValueTask<Executor>>? FactoryAsync, Type ExecutorType, object? RawValue = null)
|
||||
: IIdentified,
|
||||
IEquatable<IIdentified>,
|
||||
IEquatable<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the binding is a placeholder (i.e., does not have a factory method defined).
|
||||
/// </summary>
|
||||
[MemberNotNullWhen(false, nameof(FactoryAsync))]
|
||||
public bool IsPlaceholder => this.FactoryAsync == null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether the executor created from this binding is a shared instance across all runs.
|
||||
/// </summary>
|
||||
public abstract bool IsSharedInstance { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this binding can be used in concurrent runs
|
||||
/// from the same <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
public abstract bool SupportsConcurrentSharedExecution { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this binding can be reset between subsequent
|
||||
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
|
||||
/// cref="SupportsConcurrentSharedExecution"/>.
|
||||
/// </summary>
|
||||
public abstract bool SupportsResetting { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => $"{this.Id}:{(this.IsPlaceholder ? ":<unbound>" : this.ExecutorType.Name)}";
|
||||
|
||||
private Executor CheckId(Executor executor)
|
||||
{
|
||||
if (executor.Id != this.Id)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
|
||||
}
|
||||
|
||||
return executor;
|
||||
}
|
||||
|
||||
internal async ValueTask<Executor> CreateInstanceAsync(string runId)
|
||||
=> !this.IsPlaceholder
|
||||
? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false))
|
||||
: throw new InvalidOperationException(
|
||||
$"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder.");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool Equals(ExecutorBinding? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(IIdentified? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(string? other) =>
|
||||
other is not null && other == this.Id;
|
||||
|
||||
internal ValueTask<bool> TryResetAsync()
|
||||
{
|
||||
// Non-shared instances do not need resetting
|
||||
if (!this.IsSharedInstance)
|
||||
{
|
||||
return new(true);
|
||||
}
|
||||
|
||||
// If the executor supports concurrent use, then resetting is a no-op.
|
||||
if (!this.SupportsResetting)
|
||||
{
|
||||
return new(false);
|
||||
}
|
||||
|
||||
return this.ResetCoreAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the executor's shared resources to their initial state. Must be overridden by bindings that support
|
||||
/// resetting.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
protected virtual ValueTask<bool> ResetCoreAsync() => throw new InvalidOperationException("ExecutorBindings that support resetting must override ResetCoreAsync()");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Id.GetHashCode();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an Executor to a <see cref="ExecutorBinding"/>.
|
||||
/// </summary>
|
||||
/// <param name="executor">The Executor instance to convert.</param>
|
||||
public static implicit operator ExecutorBinding(Executor executor) => executor.BindExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a string identifier to an <see cref="ExecutorPlaceholder"/>.
|
||||
/// </summary>
|
||||
/// <param name="id">The string identifier to convert to a placeholder.</param>
|
||||
public static implicit operator ExecutorBinding(string id) => new ExecutorPlaceholder(id);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a <see cref="RequestPort "/>to an <see cref="ExecutorBinding"/>.
|
||||
/// </summary>
|
||||
/// <param name="port">The RequestPort instance to convert.</param>
|
||||
public static implicit operator ExecutorBinding(RequestPort port) => port.BindAsExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorBinding"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="agent"></param>
|
||||
public static implicit operator ExecutorBinding(AIAgent agent) => agent.BindAsExecutor();
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring executors and functions as <see cref="ExecutorBinding"/> instances.
|
||||
/// </summary>
|
||||
public static class ExecutorBindingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures an <see cref="Executor"/> instance for use in a workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
/// </remarks>
|
||||
/// <param name="executor">The executor instance.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance wrapping the specified <see cref="Executor"/>.</returns>
|
||||
public static ExecutorBinding BindExecutor(this Executor executor)
|
||||
=> new ExecutorInstanceBinding(executor);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> BindExecutor<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> factoryAsync.BindExecutor();
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> BindExecutor<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead.")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> factoryAsync.BindExecutor(id);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
|
||||
|
||||
return new ConfiguredExecutorBinding(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
[Obsolete("Use BindExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
=> factoryAsync.BindExecutor(id, options);
|
||||
|
||||
private static ConfiguredExecutorBinding ToBinding<TInput>(this FunctionExecutor<TInput> executor, Delegate raw)
|
||||
=> new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput>));
|
||||
|
||||
private static ConfiguredExecutorBinding ToBinding<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw)
|
||||
=> new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput, TOutput>));
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An ExecutorRegistration instance representing the configured sub-workflow executor.</returns>
|
||||
[Obsolete("Use BindAsExecutor() instead")]
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public static ExecutorBinding ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
=> workflow.BindAsExecutor(id, options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance representing the configured sub-workflow executor.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
=> new SubworkflowBinding(workflow, id, options);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, __) => messageHandlerAsync(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, ct) => messageHandlerAsync(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, __) => messageHandler(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, ctx, __) => messageHandler(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, ct) => messageHandler(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, __) => messageHandlerAsync(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, ct) => messageHandlerAsync(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, __) => messageHandler(input)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, ctx, __) => messageHandler(input, ctx)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based message handler as an executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
|
||||
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, ct) => messageHandler(input, ct)))
|
||||
.BindAsExecutor(id, options, threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based aggregating executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
|
||||
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorBinding BindAsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
|
||||
|
||||
/// <summary>
|
||||
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent instance.</param>
|
||||
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
|
||||
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
|
||||
=> new AIAgentBinding(agent, emitEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
|
||||
/// </summary>
|
||||
/// <param name="port">The port configuration.</param>
|
||||
/// <param name="allowWrappedRequests">Specifies whether the port should accept requests already wrapped in
|
||||
/// <see cref="ExternalRequest"/>.</param>
|
||||
/// <returns>A <see cref="RequestPortBinding"/> instance that wraps the provided port.</returns>
|
||||
public static ExecutorBinding BindAsExecutor(this RequestPort port, bool allowWrappedRequests = true)
|
||||
=> new RequestPortBinding(port, allowWrappedRequests);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for a shared executor instance, including configuration options
|
||||
/// for event emission.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorInstance">The executor instance to bind. Cannot be null.</param>
|
||||
public record ExecutorInstanceBinding(Executor ExecutorInstance)
|
||||
: ExecutorBinding(Throw.IfNull(ExecutorInstance).Id,
|
||||
(_) => new(ExecutorInstance),
|
||||
ExecutorInstance.GetType(),
|
||||
ExecutorInstance)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => this.ExecutorInstance.IsCrossRunShareable;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => this.ExecutorInstance is IResettableExecutor;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async ValueTask<bool> ResetCoreAsync()
|
||||
{
|
||||
if (this.ExecutorInstance is IResettableExecutor resettable)
|
||||
{
|
||||
await resettable.ResetAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring executors and functions as <see cref="ExecutorIsh"/> instances.
|
||||
/// </summary>
|
||||
public static class ExecutorIshConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
|
||||
/// type name as the id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Note that Executor Ids must be unique within a workflow.
|
||||
///
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
|
||||
where TExecutor : Executor
|
||||
=> ConfigureFactory<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
|
||||
where TExecutor : Executor
|
||||
=> ConfigureFactory<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
|
||||
/// the specified id and options.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
|
||||
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
|
||||
/// and it is the starting executor.
|
||||
/// </remarks>
|
||||
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
|
||||
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
|
||||
/// <param name="factoryAsync">The factory method.</param>
|
||||
/// <param name="id">An id for the executor to be instantiated.</param>
|
||||
/// <param name="options">An optional parameter specifying the options.</param>
|
||||
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
|
||||
public static ExecutorIsh ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
|
||||
where TExecutor : Executor
|
||||
where TOptions : ExecutorOptions
|
||||
{
|
||||
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
|
||||
|
||||
return new ExecutorIsh(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor), ExecutorIsh.Type.Executor);
|
||||
}
|
||||
|
||||
private static ExecutorIsh ToExecutorIsh<TInput>(this FunctionExecutor<TInput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput>),
|
||||
ExecutorIsh.Type.Function);
|
||||
|
||||
private static ExecutorIsh ToExecutorIsh<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
|
||||
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
|
||||
typeof(FunctionExecutor<TInput, TOutput>),
|
||||
ExecutorIsh.Type.Function);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
|
||||
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
|
||||
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
|
||||
/// <returns>An ExecutorIsh instance representing the configured sub-workflow executor.</returns>
|
||||
public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
|
||||
{
|
||||
object ownershipToken = new();
|
||||
workflow.TakeOwnership(ownershipToken, subworkflow: true);
|
||||
|
||||
Configured<WorkflowHostExecutor, ExecutorOptions> configured = new(InitHostExecutorAsync, id, options, raw: workflow);
|
||||
return new ExecutorIsh(configured.Super<WorkflowHostExecutor, Executor, ExecutorOptions>(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
|
||||
|
||||
ValueTask<WorkflowHostExecutor> InitHostExecutorAsync(Config<ExecutorOptions> config, string runId)
|
||||
{
|
||||
return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
|
||||
/// options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TOutput">The type of output message.</typeparam>
|
||||
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
|
||||
|
||||
/// <summary>
|
||||
/// Configures a function-based aggregating executor with the specified identifier and options.
|
||||
/// </summary>
|
||||
/// <typeparam name="TInput">The type of input message.</typeparam>
|
||||
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
|
||||
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
|
||||
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
|
||||
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
|
||||
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A tagged union representing an object that can function like an <see cref="Executor"/> in a <see cref="Workflow"/>,
|
||||
/// or a reference to one by ID.
|
||||
/// </summary>
|
||||
public sealed class ExecutorIsh :
|
||||
IIdentified,
|
||||
IEquatable<ExecutorIsh>,
|
||||
IEquatable<IIdentified>,
|
||||
IEquatable<string>
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of the <see cref="ExecutorIsh"/>.
|
||||
/// </summary>
|
||||
public enum Type
|
||||
{
|
||||
/// <summary>
|
||||
/// An unbound executor reference, identified only by ID.
|
||||
/// </summary>
|
||||
Unbound,
|
||||
/// <summary>
|
||||
/// An actual <see cref="Executor"/> instance.
|
||||
/// </summary>
|
||||
Executor,
|
||||
/// <summary>
|
||||
/// A function delegate to be wrapped as an executor.
|
||||
/// </summary>
|
||||
Function,
|
||||
/// <summary>
|
||||
/// An <see cref="RequestPort"/> for servicing external requests.
|
||||
/// </summary>
|
||||
RequestPort,
|
||||
/// <summary>
|
||||
/// An <see cref="AIAgent"/> instance.
|
||||
/// </summary>
|
||||
Agent,
|
||||
/// <summary>
|
||||
/// A nested <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
Workflow,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of data contained in this <see cref="ExecutorIsh" /> instance.
|
||||
/// </summary>
|
||||
public Type ExecutorType { get; init; }
|
||||
|
||||
private readonly string? _idValue;
|
||||
|
||||
private readonly Configured<Executor>? _configuredExecutor;
|
||||
private readonly System.Type? _configuredExecutorType;
|
||||
|
||||
internal readonly RequestPort? _requestPortValue;
|
||||
private readonly AIAgent? _aiAgentValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExecutorIsh"/> class as an unbound reference by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for an <see cref="Executor"/> in the <see cref="Workflow"/></param>
|
||||
public ExecutorIsh(string id)
|
||||
{
|
||||
this.ExecutorType = Type.Unbound;
|
||||
this._idValue = Throw.IfNull(id);
|
||||
}
|
||||
|
||||
internal ExecutorIsh(Configured<Executor> configured, System.Type configuredExecutorType, Type type)
|
||||
{
|
||||
this.ExecutorType = type;
|
||||
this._configuredExecutor = configured;
|
||||
this._configuredExecutorType = configuredExecutorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified executor.
|
||||
/// </summary>
|
||||
/// <param name="executor">The executor instance to be wrapped.</param>
|
||||
public ExecutorIsh(Executor executor)
|
||||
{
|
||||
this.ExecutorType = Type.Executor;
|
||||
this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor));
|
||||
this._configuredExecutorType = executor.GetType();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
|
||||
/// </summary>
|
||||
/// <param name="port">The input port to associate to be wrapped.</param>
|
||||
public ExecutorIsh(RequestPort port)
|
||||
{
|
||||
this.ExecutorType = Type.RequestPort;
|
||||
this._requestPortValue = Throw.IfNull(port);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the ExecutorIsh class using the specified AI agent.
|
||||
/// </summary>
|
||||
/// <param name="aiAgent"></param>
|
||||
public ExecutorIsh(AIAgent aiAgent)
|
||||
{
|
||||
this.ExecutorType = Type.Agent;
|
||||
this._aiAgentValue = Throw.IfNull(aiAgent);
|
||||
}
|
||||
|
||||
internal bool IsUnbound => this.ExecutorType == Type.Unbound;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Id => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
|
||||
Type.Executor => this._configuredExecutor!.Id,
|
||||
Type.RequestPort => this._requestPortValue!.Id,
|
||||
Type.Agent => this._aiAgentValue!.Id,
|
||||
Type.Function => this._configuredExecutor!.Id,
|
||||
Type.Workflow => this._configuredExecutor!.Id,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
internal object? RawData => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => this._idValue,
|
||||
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
Type.RequestPort => this._requestPortValue,
|
||||
Type.Agent => this._aiAgentValue,
|
||||
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registration details for the current executor.
|
||||
/// </summary>
|
||||
/// <remarks>The returned registration depends on the type of the executor. If the executor is unbound, an
|
||||
/// <see cref="InvalidOperationException"/> is thrown. For other executor types, the registration includes the
|
||||
/// appropriate ID, type, and provider based on the executor's configuration.</remarks>
|
||||
internal ExecutorRegistration Registration => new(this.Id, this.RuntimeType, this.ExecutorProvider, this.RawData);
|
||||
|
||||
private System.Type RuntimeType => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
|
||||
Type.Executor => this._configuredExecutorType!,
|
||||
Type.RequestPort => typeof(RequestInfoExecutor),
|
||||
Type.Agent => typeof(AIAgentHostExecutor),
|
||||
Type.Function => this._configuredExecutorType!,
|
||||
Type.Workflow => this._configuredExecutorType!,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets an <see cref="Func{Executor}"/> that can be used to obtain an <see cref="Executor"/> instance
|
||||
/// corresponding to this <see cref="ExecutorIsh"/>.
|
||||
/// </summary>
|
||||
private Func<string, ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
|
||||
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
|
||||
Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)),
|
||||
Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)),
|
||||
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
|
||||
Type.Workflow => this._configuredExecutor!.BoundFactoryAsync,
|
||||
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="Executor"/> instance to an <see cref="ExecutorIsh"/> object.
|
||||
/// </summary>
|
||||
/// <param name="executor">The <see cref="Executor"/> instance to convert to <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(Executor executor) => new(executor);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="RequestPort"/> to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="inputPort">The <see cref="RequestPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="aiAgent">The <see cref="AIAgent"/> to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent);
|
||||
|
||||
/// <summary>
|
||||
/// Defines an implicit conversion from a string to an <see cref="ExecutorIsh"/> instance.
|
||||
/// </summary>
|
||||
/// <param name="id">The string ID to convert to an <see cref="ExecutorIsh"/>.</param>
|
||||
public static implicit operator ExecutorIsh(string id) => new(id);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(ExecutorIsh? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(IIdentified? other) =>
|
||||
other is not null && other.Id == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Equals(string? other) =>
|
||||
other is not null && other == this.Id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool Equals(object? obj) =>
|
||||
obj switch
|
||||
{
|
||||
null => false,
|
||||
ExecutorIsh ish => this.Equals(ish),
|
||||
IIdentified identified => this.Equals(identified),
|
||||
string str => this.Equals(str),
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override int GetHashCode() => this.Id.GetHashCode();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => this.ExecutorType switch
|
||||
{
|
||||
Type.Unbound => $"'{this.Id}':<unbound>",
|
||||
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})",
|
||||
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
|
||||
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}",
|
||||
_ => $"'{this.Id}':<unknown[{this.ExecutorType}]>"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a placeholder entry for an <see cref="ExecutorBinding"/>, identified by a unique ID.
|
||||
/// </summary>
|
||||
/// <param name="Id">The unique identifier for the placeholder registration.</param>
|
||||
public record ExecutorPlaceholder(string Id)
|
||||
: ExecutorBinding(Id,
|
||||
null,
|
||||
typeof(Executor),
|
||||
Id)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
using ExecutorFactoryF = System.Func<string, System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
internal sealed class ExecutorRegistration(string id, Type executorType, ExecutorFactoryF provider, object? rawData)
|
||||
{
|
||||
public string Id { get; } = Throw.IfNullOrEmpty(id);
|
||||
public Type ExecutorType { get; } = Throw.IfNull(executorType);
|
||||
private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
|
||||
|
||||
public bool IsSharedInstance { get; } = rawData is Executor;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this registration can be reset between subsequent
|
||||
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
|
||||
/// cref="SupportsConcurrent"/>.
|
||||
/// </summary>
|
||||
public bool SupportsResetting { get; } = rawData is Executor &&
|
||||
// Cross-Run Shareable executors are "trivially" resettable, since they
|
||||
// have no on-object state.
|
||||
rawData is IResettableExecutor;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value whether instances of the executor created from this registration can be used in concurrent runs
|
||||
/// from the same <see cref="Workflow"/> instance.
|
||||
/// </summary>
|
||||
public bool SupportsConcurrent { get; } = rawData is not Executor executor || executor.IsCrossRunShareable;
|
||||
|
||||
internal async ValueTask<bool> TryResetAsync()
|
||||
{
|
||||
// Non-shared instances do not need resetting
|
||||
if (!this.IsSharedInstance)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable
|
||||
// then we would have returned in the first condition, and if rawData is not an Executor, we would have
|
||||
// returned in the second condition. That only leaves the possibility of rawData is Executor and also
|
||||
// IResettableExecutor.
|
||||
if (this.RawExecutorishData is IResettableExecutor resettableExecutor)
|
||||
{
|
||||
await resettableExecutor.ResetAsync().ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal object? RawExecutorishData { get; } = rawData;
|
||||
|
||||
public override string ToString() => $"{this.ExecutorType.Name}({this.Id})";
|
||||
|
||||
private Executor CheckId(Executor executor)
|
||||
{
|
||||
if (executor.Id != this.Id)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
|
||||
}
|
||||
|
||||
return executor;
|
||||
}
|
||||
|
||||
public async ValueTask<Executor> CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
|
||||
}
|
||||
@@ -38,7 +38,9 @@ public class FunctionExecutor<TInput>(string id,
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
|
||||
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync) : this(id, WrapAction(handlerSync))
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
|
||||
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -76,7 +78,9 @@ public class FunctionExecutor<TInput, TOutput>(string id,
|
||||
/// </summary>
|
||||
/// <param name="id">A unique identifier for the executor.</param>
|
||||
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
|
||||
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync) : this(id, WrapFunc(handlerSync))
|
||||
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
|
||||
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
|
||||
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,12 @@ public sealed class GroupChatWorkflowBuilder
|
||||
public Workflow Build()
|
||||
{
|
||||
AIAgent[] agents = this._participants.ToArray();
|
||||
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
|
||||
|
||||
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
|
||||
(string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
|
||||
|
||||
ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost));
|
||||
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
|
||||
WorkflowBuilder builder = new(host);
|
||||
|
||||
foreach (var participant in agentMap.Values)
|
||||
|
||||
@@ -73,7 +73,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
|
||||
|
||||
async Task<Executor> CreateExecutorAsync(string id)
|
||||
{
|
||||
if (!this._workflow.Registrations.TryGetValue(executorId, out var registration))
|
||||
if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration))
|
||||
{
|
||||
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the registration details for a request port, including configuration for allowing wrapped requests.
|
||||
/// </summary>
|
||||
/// <param name="Port">The request port.</param>
|
||||
/// <param name="AllowWrapped">true to allow wrapped requests to be handled by the port; otherwise, false.
|
||||
/// The default is true.</param>
|
||||
public record RequestPortBinding(RequestPort Port, bool AllowWrapped = true)
|
||||
: ExecutorBinding(Throw.IfNull(Port).Id,
|
||||
(_) => new ValueTask<Executor>(new RequestInfoExecutor(Port, AllowWrapped)),
|
||||
typeof(RequestInfoExecutor),
|
||||
Port)
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -10,11 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
|
||||
internal sealed class GroupChatHost(
|
||||
string id,
|
||||
AIAgent[] agents,
|
||||
Dictionary<AIAgent, ExecutorIsh> agentMap,
|
||||
Dictionary<AIAgent, ExecutorBinding> agentMap,
|
||||
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
|
||||
{
|
||||
private readonly AIAgent[] _agents = agents;
|
||||
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
|
||||
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
|
||||
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
|
||||
private readonly List<ChatMessage> _pendingMessages = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the workflow binding details for a subworkflow, including its instance, identifier, and optional
|
||||
/// executor options.
|
||||
/// </summary>
|
||||
/// <param name="WorkflowInstance"></param>
|
||||
/// <param name="Id"></param>
|
||||
/// <param name="ExecutorOptions"></param>
|
||||
public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorOptions? ExecutorOptions = null)
|
||||
: ExecutorBinding(Throw.IfNull(Id),
|
||||
CreateWorkflowExecutorFactory(WorkflowInstance, Id, ExecutorOptions),
|
||||
typeof(WorkflowHostExecutor),
|
||||
WorkflowInstance)
|
||||
{
|
||||
private static Func<string, ValueTask<Executor>> CreateWorkflowExecutorFactory(Workflow workflow, string id, ExecutorOptions? options)
|
||||
{
|
||||
object ownershipToken = new();
|
||||
workflow.TakeOwnership(ownershipToken, subworkflow: true);
|
||||
|
||||
return InitHostExecutorAsync;
|
||||
|
||||
ValueTask<Executor> InitHostExecutorAsync(string runId)
|
||||
{
|
||||
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool IsSharedInstance => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsConcurrentSharedExecution => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override bool SupportsResetting => false;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// </summary>
|
||||
public sealed class SwitchBuilder
|
||||
{
|
||||
private readonly List<ExecutorIsh> _executors = [];
|
||||
private readonly List<ExecutorBinding> _executors = [];
|
||||
private readonly Dictionary<string, int> _executorIndicies = [];
|
||||
private readonly List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> _caseMap = [];
|
||||
private readonly HashSet<int> _defaultIndicies = [];
|
||||
@@ -30,14 +30,14 @@ public sealed class SwitchBuilder
|
||||
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
|
||||
/// Cannot be null.</param>
|
||||
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
|
||||
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(predicate);
|
||||
Throw.IfNull(executors);
|
||||
|
||||
HashSet<int> indicies = [];
|
||||
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
@@ -60,11 +60,11 @@ public sealed class SwitchBuilder
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
|
||||
public SwitchBuilder WithDefault(params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
|
||||
{
|
||||
@@ -79,7 +79,7 @@ public sealed class SwitchBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source)
|
||||
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding source)
|
||||
{
|
||||
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
|
||||
HashSet<int> defaultIndicies = this._defaultIndicies;
|
||||
|
||||
@@ -69,7 +69,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\\n(Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.Registrations.Keys)
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
@@ -108,7 +108,7 @@ public static class WorkflowVisualizer
|
||||
|
||||
private static void EmitSubWorkflowsDigraph(Workflow workflow, List<string> lines, string indent)
|
||||
{
|
||||
foreach (var kvp in workflow.Registrations)
|
||||
foreach (var kvp in workflow.ExecutorBindings)
|
||||
{
|
||||
var execId = kvp.Key;
|
||||
var registration = kvp.Value;
|
||||
@@ -145,7 +145,7 @@ public static class WorkflowVisualizer
|
||||
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
|
||||
|
||||
// Add other executor nodes
|
||||
foreach (var executorId in workflow.Registrations.Keys)
|
||||
foreach (var executorId in workflow.ExecutorBindings.Keys)
|
||||
{
|
||||
if (executorId != startExecutorId)
|
||||
{
|
||||
@@ -264,9 +264,9 @@ public static class WorkflowVisualizer
|
||||
#endif
|
||||
}
|
||||
|
||||
private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [NotNullWhen(true)] out Workflow? workflow)
|
||||
private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(true)] out Workflow? workflow)
|
||||
{
|
||||
if (registration.RawExecutorishData is Workflow subWorkflow)
|
||||
if (binding.RawValue is Workflow subWorkflow)
|
||||
{
|
||||
workflow = subWorkflow;
|
||||
return true;
|
||||
|
||||
@@ -19,7 +19,7 @@ public class Workflow
|
||||
/// <summary>
|
||||
/// A dictionary of executor providers, keyed by executor ID.
|
||||
/// </summary>
|
||||
internal Dictionary<string, ExecutorRegistration> Registrations { get; init; } = [];
|
||||
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
|
||||
|
||||
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
|
||||
internal HashSet<string> OutputExecutors { get; init; } = [];
|
||||
@@ -41,7 +41,7 @@ public class Workflow
|
||||
/// Gets the collection of external request ports, keyed by their ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each port has a corresponding entry in the <see cref="Registrations"/> dictionary.
|
||||
/// Each port has a corresponding entry in the <see cref="ExecutorBindings"/> dictionary.
|
||||
/// </remarks>
|
||||
public Dictionary<string, RequestPortInfo> ReflectPorts()
|
||||
{
|
||||
@@ -66,10 +66,10 @@ public class Workflow
|
||||
/// </summary>
|
||||
public string? Description { get; internal init; }
|
||||
|
||||
internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent);
|
||||
internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution);
|
||||
|
||||
internal IEnumerable<string> NonConcurrentExecutorIds =>
|
||||
this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id);
|
||||
this.ExecutorBindings.Values.Where(r => !r.SupportsConcurrentSharedExecution).Select(r => r.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
|
||||
@@ -86,12 +86,14 @@ public class Workflow
|
||||
}
|
||||
|
||||
private bool _needsReset;
|
||||
private bool HasResettable => this.Registrations.Values.Any(registration => registration.SupportsResetting);
|
||||
private bool HasResettableExecutors =>
|
||||
this.ExecutorBindings.Values.Any(registration => registration.SupportsResetting);
|
||||
|
||||
private async ValueTask<bool> TryResetExecutorRegistrationsAsync()
|
||||
{
|
||||
if (this.HasResettable)
|
||||
if (this.HasResettableExecutors)
|
||||
{
|
||||
foreach (ExecutorRegistration registration in this.Registrations.Values)
|
||||
foreach (ExecutorBinding registration in this.ExecutorBindings.Values)
|
||||
{
|
||||
// TryResetAsync returns true if the executor does not need resetting
|
||||
if (!await registration.TryResetAsync().ConfigureAwait(false))
|
||||
@@ -158,7 +160,7 @@ public class Workflow
|
||||
});
|
||||
}
|
||||
|
||||
this._needsReset = this.HasResettable;
|
||||
this._needsReset = this.HasResettableExecutors;
|
||||
this._ownedAsSubworkflow = subworkflow;
|
||||
}
|
||||
|
||||
@@ -188,7 +190,7 @@ public class Workflow
|
||||
/// a <see cref="ProtocolDescriptor"/> the protocol this <see cref="Workflow"/> follows.</returns>
|
||||
public async ValueTask<ProtocolDescriptor> DescribeProtocolAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ExecutorRegistration startExecutorRegistration = this.Registrations[this.StartExecutorId];
|
||||
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
|
||||
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
|
||||
.ConfigureAwait(false);
|
||||
return startExecutor.DescribeProtocol();
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <remarks>Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out
|
||||
/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow.
|
||||
/// All executors must be bound by calling into <see cref="BindExecutor"/> if they were intially specified as
|
||||
/// <see cref="ExecutorIsh.Type.Unbound"/>.</remarks>
|
||||
/// <see cref="ExecutorBinding.IsPlaceholder"/>.</remarks>
|
||||
public class WorkflowBuilder
|
||||
{
|
||||
private readonly record struct EdgeConnection(string SourceId, string TargetId)
|
||||
@@ -28,11 +28,11 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
private int _edgeCount;
|
||||
private readonly Dictionary<string, ExecutorRegistration> _executors = [];
|
||||
private readonly Dictionary<string, ExecutorBinding> _executors = [];
|
||||
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
|
||||
private readonly HashSet<string> _unboundExecutors = [];
|
||||
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
|
||||
private readonly Dictionary<string, RequestPort> _inputPorts = [];
|
||||
private readonly Dictionary<string, RequestPort> _requestPorts = [];
|
||||
private readonly HashSet<string> _outputExecutors = [];
|
||||
|
||||
private readonly string _startExecutorId;
|
||||
@@ -46,57 +46,56 @@ public class WorkflowBuilder
|
||||
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
|
||||
/// </summary>
|
||||
/// <param name="start">The executor that defines the starting point of the workflow. Cannot be null.</param>
|
||||
public WorkflowBuilder(ExecutorIsh start)
|
||||
public WorkflowBuilder(ExecutorBinding start)
|
||||
{
|
||||
this._startExecutorId = this.Track(start).Id;
|
||||
}
|
||||
|
||||
private ExecutorIsh Track(ExecutorIsh executorish)
|
||||
private ExecutorBinding Track(ExecutorBinding registration)
|
||||
{
|
||||
// If the executor is unbound, create an entry for it, unless it already exists.
|
||||
// Otherwise, update the entry for it, and remove the unbound tag
|
||||
if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id))
|
||||
if (registration.IsPlaceholder && !this._executors.ContainsKey(registration.Id))
|
||||
{
|
||||
// If this is an unbound executor, we need to track it separately
|
||||
this._unboundExecutors.Add(executorish.Id);
|
||||
this._unboundExecutors.Add(registration.Id);
|
||||
}
|
||||
else if (!executorish.IsUnbound)
|
||||
else if (!registration.IsPlaceholder)
|
||||
{
|
||||
ExecutorRegistration incoming = executorish.Registration;
|
||||
// If there is already a bound executor with this ID, we need to validate (to best efforts)
|
||||
// that the two are matching (at least based on type)
|
||||
if (this._executors.TryGetValue(executorish.Id, out ExecutorRegistration? existing))
|
||||
if (this._executors.TryGetValue(registration.Id, out ExecutorBinding? existing))
|
||||
{
|
||||
if (existing.ExecutorType != incoming.ExecutorType)
|
||||
if (existing.ExecutorType != registration.ExecutorType)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {incoming.ExecutorType.Name}) is already bound.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {registration.ExecutorType.Name}) is already bound.");
|
||||
}
|
||||
|
||||
if (existing.RawExecutorishData is not null &&
|
||||
!ReferenceEquals(existing.RawExecutorishData, incoming.RawExecutorishData))
|
||||
if (existing.RawValue is not null &&
|
||||
!ReferenceEquals(existing.RawValue, registration.RawValue))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but different instance is already bound.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but different instance is already bound.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this._executors[executorish.Id] = executorish.Registration;
|
||||
if (this._unboundExecutors.Contains(executorish.Id))
|
||||
this._executors[registration.Id] = registration;
|
||||
if (this._unboundExecutors.Contains(registration.Id))
|
||||
{
|
||||
this._unboundExecutors.Remove(executorish.Id);
|
||||
this._unboundExecutors.Remove(registration.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort)
|
||||
if (registration is RequestPortBinding portRegistration)
|
||||
{
|
||||
RequestPort port = executorish._requestPortValue!;
|
||||
this._inputPorts[port.Id] = port;
|
||||
RequestPort port = portRegistration.Port;
|
||||
this._requestPorts[port.Id] = port;
|
||||
}
|
||||
|
||||
return executorish;
|
||||
return registration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,9 +105,9 @@ public class WorkflowBuilder
|
||||
/// </summary>
|
||||
/// <param name="executors"></param>
|
||||
/// <returns></returns>
|
||||
public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors)
|
||||
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
|
||||
{
|
||||
foreach (ExecutorIsh executor in executors)
|
||||
foreach (ExecutorBinding executor in executors)
|
||||
{
|
||||
this._outputExecutors.Add(this.Track(executor).Id);
|
||||
}
|
||||
@@ -139,21 +138,21 @@ public class WorkflowBuilder
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
|
||||
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
|
||||
/// </summary>
|
||||
/// <param name="executor">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
|
||||
/// <param name="registration">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
|
||||
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the specified executor is already bound or does not exist in the workflow.</exception>
|
||||
public WorkflowBuilder BindExecutor(Executor executor)
|
||||
public WorkflowBuilder BindExecutor(ExecutorBinding registration)
|
||||
{
|
||||
if (!this._unboundExecutors.Contains(executor.Id))
|
||||
if (Throw.IfNull(registration) is ExecutorPlaceholder)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow.");
|
||||
$"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " +
|
||||
"You must provide a concrete executor instance or registration.");
|
||||
}
|
||||
|
||||
this._executors[executor.Id] = new ExecutorIsh(executor).Registration;
|
||||
this._unboundExecutors.Remove(executor.Id);
|
||||
this.Track(registration);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -180,7 +179,7 @@ public class WorkflowBuilder
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
|
||||
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
|
||||
=> this.AddEdge<object>(source, target, null, idempotent);
|
||||
|
||||
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
|
||||
@@ -236,7 +235,7 @@ public class WorkflowBuilder
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
|
||||
/// executors already exists.</exception>
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? condition = null, bool idempotent = false)
|
||||
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
|
||||
{
|
||||
// Add an edge from source to target with an optional condition.
|
||||
// This is a low-level builder method that does not enforce any specific executor type.
|
||||
@@ -273,7 +272,7 @@ public class WorkflowBuilder
|
||||
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
|
||||
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable<ExecutorBinding> targets)
|
||||
=> this.AddFanOutEdge<object>(source, null, targets);
|
||||
|
||||
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
|
||||
@@ -305,7 +304,7 @@ public class WorkflowBuilder
|
||||
/// If null, messages will route to all targets.</param>
|
||||
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
|
||||
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorBinding> targets)
|
||||
{
|
||||
Throw.IfNull(source);
|
||||
Throw.IfNull(targets);
|
||||
@@ -339,7 +338,7 @@ public class WorkflowBuilder
|
||||
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
|
||||
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
|
||||
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
|
||||
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
|
||||
{
|
||||
Throw.IfNull(target);
|
||||
Throw.IfNull(sources);
|
||||
@@ -398,9 +397,9 @@ public class WorkflowBuilder
|
||||
|
||||
var workflow = new Workflow(this._startExecutorId, this._name, this._description)
|
||||
{
|
||||
Registrations = this._executors,
|
||||
ExecutorBindings = this._executors,
|
||||
Edges = this._edges,
|
||||
Ports = this._inputPorts,
|
||||
Ports = this._requestPorts,
|
||||
OutputExecutors = this._outputExecutors
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
|
||||
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
|
||||
|
||||
/// <summary>
|
||||
@@ -38,7 +38,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// all messages of type <typeparamref name="TMessage"/> will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
@@ -47,7 +47,7 @@ public static class WorkflowBuilderExtensions
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
if (executors is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
@@ -68,7 +68,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor from which messages will be forwarded.</param>
|
||||
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
|
||||
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(executors);
|
||||
|
||||
@@ -77,7 +77,7 @@ public static class WorkflowBuilderExtensions
|
||||
#if NET
|
||||
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
|
||||
#else
|
||||
if (executors is ICollection<ExecutorIsh> { Count: 1 })
|
||||
if (executors is ICollection<ExecutorBinding> { Count: 1 })
|
||||
#endif
|
||||
{
|
||||
return builder.AddEdge(source, executors.First(), predicate);
|
||||
@@ -102,7 +102,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
|
||||
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable<ExecutorIsh> executors)
|
||||
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable<ExecutorBinding> executors)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
@@ -139,7 +139,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor representing the external system or process to connect. Cannot be null.</param>
|
||||
/// <param name="portId">The unique identifier for the input port that will handle the external call. Cannot be null.</param>
|
||||
/// <returns>The original workflow builder instance with the external call added.</returns>
|
||||
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string portId)
|
||||
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string portId)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
@@ -160,7 +160,7 @@ public static class WorkflowBuilderExtensions
|
||||
/// <param name="source">The source executor that determines the branching condition for the switch. Cannot be null.</param>
|
||||
/// <param name="configureSwitch">An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null.</param>
|
||||
/// <returns>The workflow builder instance with the configured switch step added.</returns>
|
||||
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action<SwitchBuilder> configureSwitch)
|
||||
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorBinding source, Action<SwitchBuilder> configureSwitch)
|
||||
{
|
||||
Throw.IfNull(builder);
|
||||
Throw.IfNull(source);
|
||||
|
||||
@@ -201,11 +201,9 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
|
||||
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
int messageCount = threadMessages.Count;
|
||||
|
||||
var chatClient = this.ChatClient;
|
||||
|
||||
chatClient = ApplyRunOptionsTransformations(options, chatClient);
|
||||
@@ -221,11 +219,11 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
try
|
||||
{
|
||||
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
|
||||
responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForChatClient, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -239,7 +237,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -260,7 +258,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -272,10 +270,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
|
||||
|
||||
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
|
||||
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
|
||||
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify the AIContextProvider of all new messages.
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -348,30 +346,30 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
|
||||
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var chatClient = this.ChatClient;
|
||||
|
||||
chatClient = ApplyRunOptionsTransformations(options, chatClient);
|
||||
|
||||
var agentName = this.GetLoggingAgentName();
|
||||
var loggingAgentName = this.GetLoggingAgentName();
|
||||
|
||||
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
|
||||
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
|
||||
|
||||
// Call the IChatClient and notify the AIContextProvider of any failures.
|
||||
TChatClientResponse chatResponse;
|
||||
try
|
||||
{
|
||||
chatResponse = await chatClientRunFunc.Invoke(chatClient, threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
chatResponse = await chatClientRunFunc.Invoke(chatClient, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType, inputMessages.Count);
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType, inputMessages.Count);
|
||||
|
||||
// We can derive the type of supported thread from whether we have a conversation id,
|
||||
// so let's update it and set the conversation id for the service thread case.
|
||||
@@ -380,14 +378,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// Ensure that the author name is set for each message in the response.
|
||||
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
|
||||
{
|
||||
chatResponseMessage.AuthorName ??= agentName;
|
||||
chatResponseMessage.AuthorName ??= this.Name;
|
||||
}
|
||||
|
||||
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
|
||||
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
|
||||
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify the AIContextProvider of all new messages.
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var agentResponse = agentResponseFactoryFunc(chatResponse);
|
||||
|
||||
@@ -399,11 +397,16 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <summary>
|
||||
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
|
||||
private static async Task NotifyAIContextProviderOfSuccessAsync(
|
||||
ChatClientAgentThread thread,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
IList<ChatMessage>? aiContextProviderMessages,
|
||||
IEnumerable<ChatMessage> responseMessages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (thread.AIContextProvider is not null)
|
||||
{
|
||||
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { ResponseMessages = responseMessages },
|
||||
await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { ResponseMessages = responseMessages },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -411,11 +414,16 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <summary>
|
||||
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
|
||||
/// </summary>
|
||||
private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
|
||||
private static async Task NotifyAIContextProviderOfFailureAsync(
|
||||
ChatClientAgentThread thread,
|
||||
Exception ex,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
IList<ChatMessage>? aiContextProviderMessages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (thread.AIContextProvider is not null)
|
||||
{
|
||||
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { InvokeException = ex },
|
||||
await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { InvokeException = ex },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -556,7 +564,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <param name="runOptions">Optional parameters for agent invocation.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
|
||||
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> ThreadMessages)> PrepareThreadAndMessagesAsync(
|
||||
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> InputMessagesForChatClient, IList<ChatMessage>? AIContextProviderMessages)> PrepareThreadAndMessagesAsync(
|
||||
AgentThread? thread,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
AgentRunOptions? runOptions,
|
||||
@@ -582,7 +590,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
|
||||
}
|
||||
List<ChatMessage> threadMessages = [];
|
||||
List<ChatMessage> inputMessagesForChatClient = [];
|
||||
IList<ChatMessage>? aiContextProviderMessages = null;
|
||||
|
||||
// Populate the thread messages only if we are not continuing an existing response as it's not allowed
|
||||
if (chatOptions?.ContinuationToken is null)
|
||||
@@ -590,7 +599,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
// Add any existing messages from the thread to the messages to be sent to the chat client.
|
||||
if (typedThread.MessageStore is not null)
|
||||
{
|
||||
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
|
||||
inputMessagesForChatClient.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
// If we have an AIContextProvider, we should get context from it, and update our
|
||||
@@ -601,7 +610,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
if (aiContext.Messages is { Count: > 0 })
|
||||
{
|
||||
threadMessages.AddRange(aiContext.Messages);
|
||||
inputMessagesForChatClient.AddRange(aiContext.Messages);
|
||||
aiContextProviderMessages = aiContext.Messages;
|
||||
}
|
||||
|
||||
if (aiContext.Tools is { Count: > 0 })
|
||||
@@ -622,7 +632,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
|
||||
// Add the input messages to the end of thread messages.
|
||||
threadMessages.AddRange(inputMessages);
|
||||
inputMessagesForChatClient.AddRange(inputMessages);
|
||||
}
|
||||
|
||||
// If a user provided two different thread ids, via the thread object and options, we should throw
|
||||
@@ -649,7 +659,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatOptions.ConversationId = typedThread.ConversationId;
|
||||
}
|
||||
|
||||
return (typedThread, chatOptions, threadMessages);
|
||||
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages);
|
||||
}
|
||||
|
||||
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Data;
|
||||
/// <para>
|
||||
/// The provider supports two behaviors controlled via <see cref="TextSearchProviderOptions.SearchTime"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional instructions.</description></item>
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional messages.</description></item>
|
||||
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling"/> – Exposes a function tool that the model may invoke to retrieve contextual information when needed.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
@@ -40,11 +40,12 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
private const string DefaultContextPrompt = "## Additional Context\nConsider the following information from source documents when responding to the user:";
|
||||
private const string DefaultCitationsPrompt = "Include citations to the source document with document name and link if document name and link is available.";
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> _searchAsync;
|
||||
private readonly Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> _searchAsync;
|
||||
private readonly ILogger<TextSearchProvider>? _logger;
|
||||
private readonly AITool[] _tools;
|
||||
private readonly Queue<string> _recentMessagesText;
|
||||
private readonly TextSearchProviderOptions _options;
|
||||
private readonly List<ChatRole> _recentMessageRolesIncluded;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
|
||||
@@ -53,13 +54,14 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
/// <param name="options">Optional configuration options.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
|
||||
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
|
||||
this._options = options ?? new();
|
||||
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
|
||||
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
|
||||
this._recentMessagesText = new();
|
||||
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
|
||||
|
||||
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
|
||||
this._tools =
|
||||
@@ -85,12 +87,13 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
/// If a value was not persisted or matches the defaults it will fall back to the built-in defaults.
|
||||
/// Custom <see cref="TextSearchProviderOptions.ContextFormatter"/> delegates are not serialized.
|
||||
/// </remarks>
|
||||
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
|
||||
this._options = options ?? new();
|
||||
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
|
||||
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
|
||||
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
|
||||
|
||||
List<string>? restoredMessages = null;
|
||||
|
||||
@@ -119,12 +122,13 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
|
||||
{
|
||||
// Expose the search tool for on-demand invocation.
|
||||
return new AIContext { Tools = this._tools }; // No automatic instructions injection.
|
||||
return new AIContext { Tools = this._tools }; // No automatic message injection.
|
||||
}
|
||||
|
||||
// Aggregate text from memory + current request messages.
|
||||
var sbInput = new StringBuilder();
|
||||
foreach (var messageText in this._recentMessagesText)
|
||||
var requestMessagesText = context.RequestMessages.Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text);
|
||||
foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText))
|
||||
{
|
||||
if (sbInput.Length > 0)
|
||||
{
|
||||
@@ -133,38 +137,35 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
sbInput.Append(messageText);
|
||||
}
|
||||
|
||||
foreach (var message in context.RequestMessages)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(message?.Text))
|
||||
{
|
||||
if (sbInput.Length > 0)
|
||||
{
|
||||
sbInput.Append('\n');
|
||||
}
|
||||
sbInput.Append(message.Text);
|
||||
}
|
||||
}
|
||||
string input = sbInput.ToString();
|
||||
|
||||
// Search
|
||||
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchSearchResult> materialized = results as IList<TextSearchSearchResult> ?? results.ToList();
|
||||
if (materialized.Count == 0)
|
||||
try
|
||||
{
|
||||
this._logger?.LogWarning("TextSearchProvider: No search results found.");
|
||||
// Search
|
||||
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
|
||||
if (materialized.Count == 0)
|
||||
{
|
||||
return new AIContext();
|
||||
}
|
||||
|
||||
// Format search results
|
||||
string formatted = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }]
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error");
|
||||
return new AIContext();
|
||||
}
|
||||
|
||||
// Format search results
|
||||
string formatted = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{Input}\nContext Instructions:{Instructions}", input, formatted);
|
||||
|
||||
return new AIContext
|
||||
{
|
||||
Messages = [new ChatMessage(ChatRole.User, formatted)]
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -176,9 +177,19 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
return default; // Memory disabled.
|
||||
}
|
||||
|
||||
if (context.InvokeException is not null)
|
||||
{
|
||||
return default; // Do not update memory on failed invocations.
|
||||
}
|
||||
|
||||
var messagesText = context.RequestMessages
|
||||
.Concat(context.ResponseMessages ?? [])
|
||||
.Where(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrWhiteSpace(m.Text))
|
||||
.Where(m =>
|
||||
this._recentMessageRolesIncluded.Contains(m.Role) &&
|
||||
!string.IsNullOrWhiteSpace(m.Text) &&
|
||||
// Filter out any messages that were added by this class in InvokingAsync, since we don't want
|
||||
// a feedback loop where previous search results are used to find new search results.
|
||||
(m.AdditionalProperties == null || m.AdditionalProperties.TryGetValue("IsTextSearchProviderOutput", out bool isTextSearchProviderOutput) == false || !isTextSearchProviderOutput))
|
||||
.Select(m => m.Text)
|
||||
.ToList();
|
||||
if (messagesText.Count > limit)
|
||||
@@ -226,21 +237,21 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
internal async Task<string> SearchAsync(string userQuestion, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var results = await this._searchAsync(userQuestion, cancellationToken).ConfigureAwait(false);
|
||||
IList<TextSearchSearchResult> materialized = results as IList<TextSearchSearchResult> ?? results.ToList();
|
||||
string formatted = this.FormatResults(materialized);
|
||||
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
|
||||
string outputText = this.FormatResults(materialized);
|
||||
|
||||
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nContext Instructions:{Instructions}", userQuestion, formatted);
|
||||
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText);
|
||||
|
||||
return formatted;
|
||||
return outputText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats search results into an instructions string for model consumption.
|
||||
/// Formats search results into an output string for model consumption.
|
||||
/// </summary>
|
||||
/// <param name="results">The results.</param>
|
||||
/// <returns>Formatted string (may be empty).</returns>
|
||||
private string FormatResults(IList<TextSearchSearchResult> results)
|
||||
private string FormatResults(IList<TextSearchResult> results)
|
||||
{
|
||||
if (this._options.ContextFormatter is not null)
|
||||
{
|
||||
@@ -257,15 +268,15 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
for (int i = 0; i < results.Count; i++)
|
||||
{
|
||||
var result = results[i];
|
||||
if (!string.IsNullOrWhiteSpace(result.Name))
|
||||
if (!string.IsNullOrWhiteSpace(result.SourceName))
|
||||
{
|
||||
sb.AppendLine($"SourceDocName: {result.Name}");
|
||||
sb.AppendLine($"SourceDocName: {result.SourceName}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(result.Link))
|
||||
if (!string.IsNullOrWhiteSpace(result.SourceLink))
|
||||
{
|
||||
sb.AppendLine($"SourceDocLink: {result.Link}");
|
||||
sb.AppendLine($"SourceDocLink: {result.SourceLink}");
|
||||
}
|
||||
sb.AppendLine($"Contents: {result.Value}");
|
||||
sb.AppendLine($"Contents: {result.Text}");
|
||||
sb.AppendLine("----");
|
||||
}
|
||||
sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt);
|
||||
@@ -276,28 +287,28 @@ public sealed class TextSearchProvider : AIContextProvider
|
||||
/// <summary>
|
||||
/// Represents a single retrieved text search result.
|
||||
/// </summary>
|
||||
public sealed class TextSearchSearchResult
|
||||
public sealed class TextSearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the display name of the source document (optional).
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
public string? SourceName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a link/URL to the source document (optional).
|
||||
/// </summary>
|
||||
public string? Link { get; set; }
|
||||
public string? SourceLink { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the textual content of the retrieved chunk.
|
||||
/// </summary>
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the raw representation of the search result from the data source.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// If a <see cref="TextSearchSearchResult"/> is created to represent some underlying object from another object
|
||||
/// If a <see cref="TextSearchResult"/> is created to represent some underlying object from another object
|
||||
/// model, this property can be used to store that original object. This can be useful for debugging or
|
||||
/// for enabling the <see cref="TextSearchProviderOptions.ContextFormatter"/> to access the underlying object model if needed.
|
||||
/// </remarks>
|
||||
|
||||
@@ -30,12 +30,12 @@ public sealed class TextSearchProviderOptions
|
||||
public string? FunctionToolDescription { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the context prompt prefixed to automatically injected results.
|
||||
/// Gets or sets the context prompt prefixed to results.
|
||||
/// </summary>
|
||||
public string? ContextPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the instruction appended after automatically injected results to request citations.
|
||||
/// Gets or sets the instruction appended after results to request citations.
|
||||
/// </summary>
|
||||
public string? CitationsPrompt { get; set; }
|
||||
|
||||
@@ -45,7 +45,7 @@ public sealed class TextSearchProviderOptions
|
||||
/// <remarks>
|
||||
/// If provided, <see cref="ContextPrompt"/> and <see cref="CitationsPrompt"/> are ignored.
|
||||
/// </remarks>
|
||||
public Func<IList<TextSearchProvider.TextSearchSearchResult>, string>? ContextFormatter { get; set; }
|
||||
public Func<IList<TextSearchProvider.TextSearchResult>, string>? ContextFormatter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of recent conversation messages (both user and assistant) to keep in memory
|
||||
@@ -59,13 +59,33 @@ public sealed class TextSearchProviderOptions
|
||||
/// </value>
|
||||
public int RecentMessageMemoryLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of <see cref="ChatRole"/> types to filter recent messages to
|
||||
/// when deciding which recent messages to include when constructing the search input.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Depending on your scenario, you may want to use only user messages, only assistant messages,
|
||||
/// or both. For example, if the assistant may often provide clarifying questions or if the conversation
|
||||
/// is expected to be particularly chatty, you may want to include assistant messages in the search context as well.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Be careful when including assistant messages though, as they may skew the search results towards
|
||||
/// information that has already been provided by the assistant, rather than focusing on the user's current needs.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <value>
|
||||
/// When not specified, defaults to only <see cref="ChatRole.User"/>.
|
||||
/// </value>
|
||||
public List<ChatRole>? RecentMessageRolesIncluded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Behavior choices for the provider.
|
||||
/// </summary>
|
||||
public enum TextSearchBehavior
|
||||
{
|
||||
/// <summary>
|
||||
/// Execute search prior to each invocation and inject results as instructions.
|
||||
/// Execute search prior to each invocation and inject results as a message.
|
||||
/// </summary>
|
||||
BeforeAIInvoke,
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
|
||||
</ItemGroup>
|
||||
@@ -31,6 +32,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
+3
-3
@@ -31,10 +31,10 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAIAgentAsync_ReturnsAIAgentAsync()
|
||||
public void GetAIAgent_ReturnsAIAgent()
|
||||
{
|
||||
// Act
|
||||
var agent = await this._agentCard.GetAIAgentAsync();
|
||||
var agent = this._agentCard.GetAIAgent();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -56,7 +56,7 @@ public sealed class A2AAgentCardExtensionsTests
|
||||
Parts = [new TextPart { Text = "Response" }],
|
||||
});
|
||||
|
||||
var agent = await this._agentCard.GetAIAgentAsync(httpClient);
|
||||
var agent = this._agentCard.GetAIAgent(httpClient);
|
||||
|
||||
// Act
|
||||
await agent.RunAsync("Test input");
|
||||
|
||||
@@ -16,7 +16,7 @@ public class AIContextProviderTests
|
||||
{
|
||||
var provider = new TestAIContextProvider();
|
||||
var messages = new ReadOnlyCollection<ChatMessage>([]);
|
||||
var task = provider.InvokedAsync(new(messages));
|
||||
var task = provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
Assert.Equal(default, task);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AIContextProviderTests
|
||||
[Fact]
|
||||
public void InvokedContext_Constructor_ThrowsForNullMessages()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!, aiContextProviderMessages: null));
|
||||
}
|
||||
|
||||
#region GetService Method Tests
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input }));
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input }, aiContextProviderMessages: null));
|
||||
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
|
||||
@@ -77,7 +77,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }));
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null));
|
||||
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
|
||||
await sut.ClearStoredMemoriesAsync();
|
||||
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
|
||||
@@ -105,7 +105,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty);
|
||||
|
||||
// Act
|
||||
await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }));
|
||||
await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null));
|
||||
var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question);
|
||||
var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Mem0.UnitTests;
|
||||
|
||||
@@ -17,13 +18,23 @@ namespace Microsoft.Agents.AI.Mem0.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
private readonly Mock<ILogger<Mem0Provider>> _loggerMock;
|
||||
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
|
||||
private readonly RecordingHandler _handler = new();
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b => b.AddProvider(new NullLoggerProvider()));
|
||||
private bool _disposed;
|
||||
|
||||
public Mem0ProviderTests()
|
||||
{
|
||||
this._loggerMock = new();
|
||||
this._loggerFactoryMock = new();
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(It.IsAny<string>()))
|
||||
.Returns(this._loggerMock.Object);
|
||||
this._loggerFactoryMock
|
||||
.Setup(f => f.CreateLogger(typeof(Mem0Provider).FullName!))
|
||||
.Returns(this._loggerMock.Object);
|
||||
|
||||
this._httpClient = new HttpClient(this._handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://localhost/")
|
||||
@@ -80,7 +91,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
ThreadId = "thread",
|
||||
UserId = "user"
|
||||
};
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactory);
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
|
||||
|
||||
// Act
|
||||
@@ -99,6 +110,24 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
var contextMessage = Assert.Single(aiContext.Messages);
|
||||
Assert.Equal(ChatRole.User, contextMessage.Role);
|
||||
Assert.Contains("Name is Caoimhe", contextMessage.Text);
|
||||
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Information,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Retrieved 1 memories.")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Search Results\nInput:What is my name?\nOutput")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -123,8 +152,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages)); // persists request messages
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(responseMessages)); // persists assistant
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
var memoryPosts = this._handler.Requests.Where(r => r.RequestMessage.RequestUri!.AbsolutePath == "/v1/memories/" && r.RequestMessage.Method == HttpMethod.Post).ToList();
|
||||
@@ -136,6 +164,60 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
Assert.DoesNotContain(memoryPosts, r => ContainsOrdinal(r.RequestBody, "Tool text"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_PersistsNothingForFailedRequestAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
|
||||
var sut = new Mem0Provider(this._httpClient, options);
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "User text"),
|
||||
new(ChatRole.System, "System text"),
|
||||
new(ChatRole.Tool, "Tool text should be ignored")
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = null, InvokeException = new InvalidOperationException("Request Failed") });
|
||||
|
||||
// Assert
|
||||
Assert.Empty(this._handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
|
||||
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
|
||||
this._handler.EnqueueEmptyInternalServerError();
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "User text"),
|
||||
new(ChatRole.System, "System text"),
|
||||
new(ChatRole.Tool, "Tool text should be ignored")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, "Assistant text")
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
|
||||
|
||||
// Assert
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to send messages to Mem0 due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
|
||||
{
|
||||
@@ -247,6 +329,30 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new Mem0ProviderOptions { ApplicationId = "app" };
|
||||
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Tools);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to search Mem0 for memories due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0;
|
||||
|
||||
public void Dispose()
|
||||
@@ -255,7 +361,6 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
{
|
||||
this._httpClient.Dispose();
|
||||
this._handler.Dispose();
|
||||
this._loggerFactory.Dispose();
|
||||
this._disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -289,18 +394,7 @@ public sealed class Mem0ProviderTests : IDisposable
|
||||
}
|
||||
|
||||
public void EnqueueEmptyOk() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
|
||||
}
|
||||
|
||||
private sealed class NullLoggerProvider : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) => new NullLogger();
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class NullLogger : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => false;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
|
||||
}
|
||||
public void EnqueueEmptyInternalServerError() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +194,10 @@ public partial class ChatClientAgentTests
|
||||
/// <summary>
|
||||
/// Verify that RunAsync sets AuthorName on all response messages.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncSetsAuthorNameOnAllResponseMessagesAsync()
|
||||
[Theory]
|
||||
[InlineData("TestAgent")]
|
||||
[InlineData(null)]
|
||||
public async Task RunAsyncSetsAuthorNameOnAllResponseMessagesAsync(string? authorName)
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
@@ -210,20 +212,20 @@ public partial class ChatClientAgentTests
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse(responseMessages));
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions", Name = "TestAgent" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "test instructions", Name = authorName });
|
||||
|
||||
// Act
|
||||
var result = await agent.RunAsync([new(ChatRole.User, "test")]);
|
||||
|
||||
// Assert
|
||||
Assert.All(result.Messages, msg => Assert.Equal("TestAgent", msg.AuthorName));
|
||||
Assert.All(result.Messages, msg => Assert.Equal(authorName, msg.AuthorName));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunAsync works with existing thread and retrieves messages from IMessagesRetrievableThread.
|
||||
/// Verify that RunAsync works with existing thread and can retreive messages if the thread has a MessageStore.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadImplementsIMessagesRetrievableThreadAsync()
|
||||
public async Task RunAsyncRetrievesMessagesFromThreadWhenThreadStoresMessagesThreadAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
@@ -449,7 +451,10 @@ public partial class ChatClientAgentTests
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], thread);
|
||||
|
||||
// Assert
|
||||
Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
Assert.Equal(2, messageStore.Count);
|
||||
Assert.Equal("test", messageStore[0].Text);
|
||||
Assert.Equal("response", messageStore[1].Text);
|
||||
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>()), Times.Once);
|
||||
}
|
||||
|
||||
@@ -492,6 +497,7 @@ public partial class ChatClientAgentTests
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")];
|
||||
ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
string capturedInstructions = string.Empty;
|
||||
@@ -517,7 +523,7 @@ public partial class ChatClientAgentTests
|
||||
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = [new(ChatRole.System, "context provider message")],
|
||||
Messages = aiContextProviderMessages,
|
||||
Instructions = "context provider instructions",
|
||||
Tools = [AIFunctionFactory.Create(() => { }, "context provider function")]
|
||||
});
|
||||
@@ -528,7 +534,8 @@ public partial class ChatClientAgentTests
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
|
||||
|
||||
// Act
|
||||
await agent.RunAsync(requestMessages);
|
||||
var thread = agent.GetNewThread() as ChatClientAgentThread;
|
||||
await agent.RunAsync(requestMessages, thread);
|
||||
|
||||
// Assert
|
||||
// Should contain: base instructions, context message, user message, base function, context function
|
||||
@@ -541,8 +548,20 @@ public partial class ChatClientAgentTests
|
||||
Assert.Equal(2, capturedTools.Count);
|
||||
Assert.Contains(capturedTools, t => t.Name == "base function");
|
||||
Assert.Contains(capturedTools, t => t.Name == "context provider function");
|
||||
|
||||
// Verify that the thread was updated with the input, ai context and response messages
|
||||
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
Assert.Equal(3, messageStore.Count);
|
||||
Assert.Equal("user message", messageStore[0].Text);
|
||||
Assert.Equal("context provider message", messageStore[1].Text);
|
||||
Assert.Equal("response", messageStore[2].Text);
|
||||
|
||||
mockProvider.Verify(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x => x.RequestMessages == requestMessages && x.ResponseMessages == responseMessages && x.InvokeException == null), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.RequestMessages == requestMessages &&
|
||||
x.AIContextProviderMessages == aiContextProviderMessages &&
|
||||
x.ResponseMessages == responseMessages &&
|
||||
x.InvokeException == null), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -554,6 +573,7 @@ public partial class ChatClientAgentTests
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatMessage[] responseMessages = [new(ChatRole.Assistant, "response")];
|
||||
ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService
|
||||
.Setup(s => s.GetResponseAsync(
|
||||
@@ -565,7 +585,10 @@ public partial class ChatClientAgentTests
|
||||
var mockProvider = new Mock<AIContextProvider>();
|
||||
mockProvider
|
||||
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AIContext());
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = aiContextProviderMessages,
|
||||
});
|
||||
mockProvider
|
||||
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask());
|
||||
@@ -577,7 +600,11 @@ public partial class ChatClientAgentTests
|
||||
|
||||
// Assert
|
||||
mockProvider.Verify(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x => x.RequestMessages == requestMessages && x.ResponseMessages == null && x.InvokeException is InvalidOperationException), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.RequestMessages == requestMessages &&
|
||||
x.AIContextProviderMessages == aiContextProviderMessages &&
|
||||
x.ResponseMessages == null &&
|
||||
x.InvokeException is InvalidOperationException), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1879,7 +1906,10 @@ public partial class ChatClientAgentTests
|
||||
await agent.RunStreamingAsync([new(ChatRole.User, "test")], thread).ToListAsync();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
Assert.Equal(2, messageStore.Count);
|
||||
Assert.Equal("test", messageStore[0].Text);
|
||||
Assert.Equal("what?", messageStore[1].Text);
|
||||
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>()), Times.Once);
|
||||
}
|
||||
|
||||
@@ -1918,6 +1948,130 @@ public partial class ChatClientAgentTests
|
||||
mockFactory.Verify(f => f(It.IsAny<ChatClientAgentOptions.ChatMessageStoreFactoryContext>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunStreamingAsync invokes any provided AIContextProvider and uses the result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncInvokesAIContextProviderAndUsesResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatResponseUpdate[] responseUpdates = [new(ChatRole.Assistant, "response")];
|
||||
ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
string capturedInstructions = string.Empty;
|
||||
List<AITool> capturedTools = [];
|
||||
mockService
|
||||
.Setup(s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>((msgs, opts, ct) =>
|
||||
{
|
||||
capturedMessages.AddRange(msgs);
|
||||
capturedInstructions = opts.Instructions ?? string.Empty;
|
||||
if (opts.Tools is not null)
|
||||
{
|
||||
capturedTools.AddRange(opts.Tools);
|
||||
}
|
||||
})
|
||||
.Returns(ToAsyncEnumerableAsync(responseUpdates));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>();
|
||||
mockProvider
|
||||
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = aiContextProviderMessages,
|
||||
Instructions = "context provider instructions",
|
||||
Tools = [AIFunctionFactory.Create(() => { }, "context provider function")]
|
||||
});
|
||||
mockProvider
|
||||
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
|
||||
|
||||
// Act
|
||||
var thread = agent.GetNewThread() as ChatClientAgentThread;
|
||||
var updates = agent.RunStreamingAsync(requestMessages, thread);
|
||||
_ = await updates.ToAgentRunResponseAsync();
|
||||
|
||||
// Assert
|
||||
// Should contain: base instructions, context message, user message, base function, context function
|
||||
Assert.Equal(2, capturedMessages.Count);
|
||||
Assert.Equal("base instructions\ncontext provider instructions", capturedInstructions);
|
||||
Assert.Equal("context provider message", capturedMessages[0].Text);
|
||||
Assert.Equal(ChatRole.System, capturedMessages[0].Role);
|
||||
Assert.Equal("user message", capturedMessages[1].Text);
|
||||
Assert.Equal(ChatRole.User, capturedMessages[1].Role);
|
||||
Assert.Equal(2, capturedTools.Count);
|
||||
Assert.Contains(capturedTools, t => t.Name == "base function");
|
||||
Assert.Contains(capturedTools, t => t.Name == "context provider function");
|
||||
|
||||
// Verify that the thread was updated with the input, ai context and response messages
|
||||
var messageStore = Assert.IsType<InMemoryChatMessageStore>(thread!.MessageStore);
|
||||
Assert.Equal(3, messageStore.Count);
|
||||
Assert.Equal("user message", messageStore[0].Text);
|
||||
Assert.Equal("context provider message", messageStore[1].Text);
|
||||
Assert.Equal("response", messageStore[2].Text);
|
||||
|
||||
mockProvider.Verify(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.RequestMessages == requestMessages &&
|
||||
x.AIContextProviderMessages == aiContextProviderMessages &&
|
||||
x.ResponseMessages!.Count() == 1 &&
|
||||
x.ResponseMessages!.ElementAt(0).Text == "response" &&
|
||||
x.InvokeException == null), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that RunStreamingAsync invokes any provided AIContextProvider when the downstream GetStreamingResponse call fails.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncInvokesAIContextProviderWhenGetResponseFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatMessage[] requestMessages = [new(ChatRole.User, "user message")];
|
||||
ChatMessage[] aiContextProviderMessages = [new(ChatRole.System, "context provider message")];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService
|
||||
.Setup(s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Throws(new InvalidOperationException("downstream failure"));
|
||||
|
||||
var mockProvider = new Mock<AIContextProvider>();
|
||||
mockProvider
|
||||
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AIContext
|
||||
{
|
||||
Messages = aiContextProviderMessages,
|
||||
});
|
||||
mockProvider
|
||||
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgent agent = new(mockService.Object, options: new() { Instructions = "base instructions", AIContextProviderFactory = _ => mockProvider.Object, ChatOptions = new() { Tools = [AIFunctionFactory.Create(() => { }, "base function")] } });
|
||||
|
||||
// Act
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
|
||||
{
|
||||
var updates = agent.RunStreamingAsync(requestMessages);
|
||||
await updates.ToAgentRunResponseAsync();
|
||||
});
|
||||
|
||||
// Assert
|
||||
mockProvider.Verify(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
mockProvider.Verify(p => p.InvokedAsync(It.Is<AIContextProvider.InvokedContext>(x =>
|
||||
x.RequestMessages == requestMessages &&
|
||||
x.AIContextProviderMessages == aiContextProviderMessages &&
|
||||
x.ResponseMessages == null &&
|
||||
x.InvokeException is InvalidOperationException), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetNewThread Tests
|
||||
|
||||
@@ -39,17 +39,17 @@ public sealed class TextSearchProviderTests
|
||||
public async Task InvokingAsync_ShouldInjectFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt, bool withLogging)
|
||||
{
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchSearchResult> results =
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>(results);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
@@ -116,7 +116,7 @@ public sealed class TextSearchProviderTests
|
||||
l => l.Log(
|
||||
LogLevel.Trace,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider Input:Sample user question?\nAdditional part\nContext Instructions")),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Search Results\nInput:Sample user question?\nAdditional part\nOutput")),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
@@ -150,21 +150,44 @@ public sealed class TextSearchProviderTests
|
||||
Assert.Equal(expectedDescription, tool.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new TextSearchProvider(this.FailingSearchAsync, loggerFactory: this._loggerFactoryMock.Object);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
|
||||
|
||||
// Act
|
||||
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Null(aiContext.Messages);
|
||||
Assert.Null(aiContext.Tools);
|
||||
this._loggerMock.Verify(
|
||||
l => l.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("TextSearchProvider: Failed to search for data due to error")),
|
||||
It.IsAny<Exception>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.AtLeastOnce);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("Custom context prompt", "Custom citations prompt")]
|
||||
public async Task SearchAsync_ShouldReturnFormattedResultsAsync(string? overrideContextPrompt, string? overrideCitationsPrompt)
|
||||
{
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchSearchResult> results =
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>(results);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
@@ -208,15 +231,15 @@ public sealed class TextSearchProviderTests
|
||||
public async Task InvokingAsync_ShouldUseContextFormatterWhenProvidedAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<TextSearchProvider.TextSearchSearchResult> results =
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Link = "http://example.com/doc1", Value = "Content of Doc1" },
|
||||
new() { Name = "Doc2", Link = "http://example.com/doc2", Value = "Content of Doc2" }
|
||||
new() { SourceName = "Doc1", SourceLink = "http://example.com/doc1", Text = "Content of Doc1" },
|
||||
new() { SourceName = "Doc2", SourceLink = "http://example.com/doc2", Text = "Content of Doc2" }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>(results);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
@@ -242,15 +265,15 @@ public sealed class TextSearchProviderTests
|
||||
// Arrange
|
||||
var payload1 = new RawPayload { Id = "R1" };
|
||||
var payload2 = new RawPayload { Id = "R2" };
|
||||
List<TextSearchProvider.TextSearchSearchResult> results =
|
||||
List<TextSearchProvider.TextSearchResult> results =
|
||||
[
|
||||
new() { Name = "Doc1", Value = "Content 1", RawRepresentation = payload1 },
|
||||
new() { Name = "Doc2", Value = "Content 2", RawRepresentation = payload2 }
|
||||
new() { SourceName = "Doc1", Text = "Content 1", RawRepresentation = payload1 },
|
||||
new() { SourceName = "Doc2", Text = "Content 2", RawRepresentation = payload2 }
|
||||
];
|
||||
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>(results);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
|
||||
}
|
||||
|
||||
var options = new TextSearchProviderOptions
|
||||
@@ -290,7 +313,7 @@ public sealed class TextSearchProviderTests
|
||||
#region Recent Message Memory Tests
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync()
|
||||
public async Task InvokingAsync_WithPreviousFailedRequest_ShouldNotIncludeFailedRequestInputInSearchInputAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
@@ -299,10 +322,10 @@ public sealed class TextSearchProviderTests
|
||||
RecentMessageMemoryLimit = 3
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]); // No results needed.
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, options);
|
||||
|
||||
@@ -314,7 +337,47 @@ public sealed class TextSearchProviderTests
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
new ChatMessage(ChatRole.Assistant, "D"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages));
|
||||
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null) { InvokeException = new InvalidOperationException("Request Failed") });
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "E")
|
||||
});
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("E", capturedInput); // Only the messages from the current request, since previous failed request should not be stored.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithRecentMessageMemory_ShouldIncludeStoredMessagesInSearchInputAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, options);
|
||||
|
||||
// Populate memory with more messages than the limit (A,B,C,D) -> should retain B,C,D
|
||||
var initialMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
new ChatMessage(ChatRole.Assistant, "D"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages, aiContextProviderMessages: null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[]
|
||||
{
|
||||
@@ -335,13 +398,14 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 5
|
||||
RecentMessageMemoryLimit = 5,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, options);
|
||||
|
||||
@@ -350,7 +414,7 @@ public sealed class TextSearchProviderTests
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "A"),
|
||||
new ChatMessage(ChatRole.Assistant, "B"),
|
||||
}));
|
||||
}, aiContextProviderMessages: null));
|
||||
|
||||
// Second memory update (C,D,E)
|
||||
await provider.InvokedAsync(new(new[]
|
||||
@@ -358,7 +422,7 @@ public sealed class TextSearchProviderTests
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
new ChatMessage(ChatRole.Assistant, "D"),
|
||||
new ChatMessage(ChatRole.User, "E"),
|
||||
}));
|
||||
}, aiContextProviderMessages: null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "F") });
|
||||
|
||||
@@ -369,6 +433,46 @@ public sealed class TextSearchProviderTests
|
||||
Assert.Equal("A\nB\nC\nD\nE\nF", capturedInput); // All retained (limit 5) + current request message.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingAsync_WithRecentMessageRolesIncluded_ShouldFilterRolesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4,
|
||||
RecentMessageRolesIncluded = new List<ChatRole> { ChatRole.Assistant } // Only retain assistant messages.
|
||||
};
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegateAsync(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]); // No results needed for this test.
|
||||
}
|
||||
var provider = new TextSearchProvider(SearchDelegateAsync, options);
|
||||
|
||||
// Populate memory with mixed roles; only Assistant messages (A1,A2) should be retained.
|
||||
var initialMessages = new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "U1"),
|
||||
new ChatMessage(ChatRole.Assistant, "A1"),
|
||||
new ChatMessage(ChatRole.User, "U2"),
|
||||
new ChatMessage(ChatRole.Assistant, "A2"),
|
||||
};
|
||||
await provider.InvokedAsync(new(initialMessages, null));
|
||||
|
||||
var invokingContext = new AIContextProvider.InvokingContext(new[]
|
||||
{
|
||||
new ChatMessage(ChatRole.User, "Question?") // Current request message always appended.
|
||||
});
|
||||
|
||||
// Act
|
||||
await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("A1\nA2\nQuestion?", capturedInput); // Only assistant messages from memory + current request.
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
@@ -399,7 +503,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 3
|
||||
RecentMessageMemoryLimit = 3,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
|
||||
var messages = new[]
|
||||
@@ -410,7 +515,7 @@ public sealed class TextSearchProviderTests
|
||||
};
|
||||
|
||||
// Act
|
||||
await provider.InvokedAsync(new(messages)); // Populate recent memory.
|
||||
await provider.InvokedAsync(new(messages, aiContextProviderMessages: null)); // Populate recent memory.
|
||||
var state = provider.Serialize();
|
||||
|
||||
// Assert
|
||||
@@ -428,7 +533,8 @@ public sealed class TextSearchProviderTests
|
||||
var options = new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 4
|
||||
RecentMessageMemoryLimit = 4,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
};
|
||||
var provider = new TextSearchProvider(this.NoResultSearchAsync, options);
|
||||
var messages = new[]
|
||||
@@ -438,15 +544,15 @@ public sealed class TextSearchProviderTests
|
||||
new ChatMessage(ChatRole.User, "C"),
|
||||
new ChatMessage(ChatRole.Assistant, "D"),
|
||||
};
|
||||
await provider.InvokedAsync(new(messages));
|
||||
await provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
|
||||
// Act
|
||||
var state = provider.Serialize();
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
var roundTrippedProvider = new TextSearchProvider(SearchDelegate2Async, state, options: new TextSearchProviderOptions
|
||||
{
|
||||
@@ -468,7 +574,8 @@ public sealed class TextSearchProviderTests
|
||||
var initialProvider = new TextSearchProvider(this.NoResultSearchAsync, new TextSearchProviderOptions
|
||||
{
|
||||
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
|
||||
RecentMessageMemoryLimit = 5
|
||||
RecentMessageMemoryLimit = 5,
|
||||
RecentMessageRolesIncluded = [ChatRole.User, ChatRole.Assistant]
|
||||
});
|
||||
var messages = new[]
|
||||
{
|
||||
@@ -478,14 +585,14 @@ public sealed class TextSearchProviderTests
|
||||
new ChatMessage(ChatRole.Assistant, "L4"),
|
||||
new ChatMessage(ChatRole.User, "L5"),
|
||||
};
|
||||
await initialProvider.InvokedAsync(new(messages));
|
||||
await initialProvider.InvokedAsync(new(messages, aiContextProviderMessages: null));
|
||||
var state = initialProvider.Serialize();
|
||||
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
// Act
|
||||
@@ -508,10 +615,10 @@ public sealed class TextSearchProviderTests
|
||||
var emptyState = JsonSerializer.Deserialize("{}", TestJsonSerializerContext.Default.JsonElement);
|
||||
|
||||
string? capturedInput = null;
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
Task<IEnumerable<TextSearchProvider.TextSearchResult>> SearchDelegate2Async(string input, CancellationToken ct)
|
||||
{
|
||||
capturedInput = input;
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
// Act
|
||||
@@ -530,9 +637,14 @@ public sealed class TextSearchProviderTests
|
||||
|
||||
#endregion
|
||||
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> NoResultSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchSearchResult>>([]);
|
||||
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>([]);
|
||||
}
|
||||
|
||||
private Task<IEnumerable<TextSearchProvider.TextSearchResult>> FailingSearchAsync(string input, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Search Failed");
|
||||
}
|
||||
|
||||
private sealed class RawPayload
|
||||
|
||||
+57
-6
@@ -225,6 +225,54 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
Assert.True(visitor.HasUnsupportedActions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CaseInsensitive.yaml", "end_when_match")]
|
||||
[InlineData("ClearAllVariables.yaml", "clear_all")]
|
||||
[InlineData("Condition.yaml", "setVariable_test")]
|
||||
[InlineData("ConditionElse.yaml", "setVariable_test")]
|
||||
[InlineData("EndConversation.yaml", "end_all")]
|
||||
[InlineData("EndDialog.yaml", "end_all")]
|
||||
[InlineData("EditTable.yaml", "edit_var")]
|
||||
[InlineData("EditTableV2.yaml", "edit_var")]
|
||||
[InlineData("Goto.yaml", "goto_end")]
|
||||
[InlineData("LoopBreak.yaml", "break_loop_now")]
|
||||
[InlineData("LoopContinue.yaml", "foreach_loop")]
|
||||
[InlineData("LoopEach.yaml", "foreach_loop")]
|
||||
[InlineData("MixedScopes.yaml", "activity_input")]
|
||||
[InlineData("ParseValue.yaml", "parse_var")]
|
||||
[InlineData("ParseValueList.yaml", "parse_var")]
|
||||
[InlineData("ResetVariable.yaml", "clear_var")]
|
||||
[InlineData("SendActivity.yaml", "activity_input")]
|
||||
[InlineData("SetVariable.yaml", "set_var")]
|
||||
[InlineData("SetTextVariable.yaml", "set_text")]
|
||||
public async Task CancelRunAsync(string workflowPath, string expectedExecutedId)
|
||||
{
|
||||
// Arrange
|
||||
const string WorkflowInput = "Test input message";
|
||||
Workflow workflow = this.CreateWorkflow(workflowPath, WorkflowInput);
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow: workflow, input: WorkflowInput);
|
||||
|
||||
// Act
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||
{
|
||||
this.WorkflowEvents.Add(workflowEvent);
|
||||
|
||||
if (workflowEvent is DeclarativeActionInvokedEvent actionInvokedEvent && actionInvokedEvent.ActionId == expectedExecutedId)
|
||||
{
|
||||
// Cancel run after the specified declarative action is invoked.
|
||||
await run.CancelRunAsync();
|
||||
}
|
||||
}
|
||||
RunStatus currentRunStatus = await run.GetStatusAsync();
|
||||
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expected: RunStatus.Ended, actual: currentRunStatus);
|
||||
Assert.NotEmpty(this.WorkflowEventCounts);
|
||||
Assert.Contains(this.WorkflowEvents.OfType<DeclarativeActionInvokedEvent>(), e => e.ActionId == expectedExecutedId);
|
||||
Assert.DoesNotContain(this.WorkflowEvents.OfType<DeclarativeActionCompletedEvent>(), e => e.ActionId == expectedExecutedId);
|
||||
}
|
||||
|
||||
private void AssertExecutionCount(int expectedCount)
|
||||
{
|
||||
Assert.Equal(expectedCount + 2, this.WorkflowEventCounts[typeof(ExecutorInvokedEvent)]);
|
||||
@@ -256,12 +304,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
|
||||
private async Task RunWorkflowAsync<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
|
||||
{
|
||||
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
|
||||
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
|
||||
Workflow workflow = this.CreateWorkflow(workflowPath, workflowInput);
|
||||
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput);
|
||||
|
||||
await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
|
||||
@@ -303,6 +346,14 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
|
||||
this.WorkflowEventCounts = this.WorkflowEvents.GroupBy(e => e.GetType()).ToDictionary(e => e.Key, e => e.Count());
|
||||
}
|
||||
|
||||
private Workflow CreateWorkflow<TInput>(string workflowPath, TInput workflowInput) where TInput : notnull
|
||||
{
|
||||
using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowPath));
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = CreateMockProvider($"{workflowInput}");
|
||||
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
|
||||
return DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
|
||||
}
|
||||
|
||||
private static Mock<WorkflowAgentProvider> CreateMockProvider(string input)
|
||||
{
|
||||
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
|
||||
|
||||
@@ -167,7 +167,7 @@ public class JsonSerializationTests
|
||||
builder.AddEdge(forwardString, stringToInt)
|
||||
.AddEdge(stringToInt, forwardInt)
|
||||
.AddEdge(forwardInt, intToString)
|
||||
.AddEdge(intToString, StreamingAggregators.Last<int>().AsExecutor("Aggregate"));
|
||||
.AddEdge(intToString, StreamingAggregators.Last<int>().BindAsExecutor("Aggregate"));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user