mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52da946efd | ||
|
|
859ac0939d | ||
|
|
c40e5a9020 | ||
|
|
12256b59aa | ||
|
|
04fdf25019 | ||
|
|
29fb22f7d8 | ||
|
|
38fc0b8e08 | ||
|
|
34114f8d7b | ||
|
|
38c8f3ec18 | ||
|
|
6a49da6f1c | ||
|
|
d3bfbcbf52 | ||
|
|
588e0bc0b2 | ||
|
|
00650f2525 | ||
|
|
530f8b389a | ||
|
|
8c3182e4e8 | ||
|
|
5ddb4cd546 | ||
|
|
50662c3415 | ||
|
|
6fbb4dcb87 | ||
|
|
ff230c86ce | ||
|
|
6e029eb039 | ||
|
|
defb533b95 | ||
|
|
7f22a87a24 | ||
|
|
4340f37e97 | ||
|
|
aea354b09c | ||
|
|
da6b2534c2 | ||
|
|
6445b6b3a6 | ||
|
|
d28ad2d7df | ||
|
|
88968da0bd | ||
|
|
50d34aec91 | ||
|
|
13a5b70703 | ||
|
|
9c04196491 | ||
|
|
01c5aabda5 | ||
|
|
3f7ea350dc | ||
|
|
92435c6ab5 | ||
|
|
f6086e4ccd | ||
|
|
99fac4ca56 | ||
|
|
7aa72f6fdb | ||
|
|
49cecf324c | ||
|
|
b88b2c3190 | ||
|
|
ab493af110 | ||
|
|
33888641ec | ||
|
|
299a5110ed | ||
|
|
f508f1d6da |
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.0.0" />
|
||||
<PackageVersion Include="Anthropic" Version="12.0.1" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.1.0" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
|
||||
<!-- Google Gemini -->
|
||||
<PackageVersion Include="Google.GenAI" Version="0.6.0" />
|
||||
<PackageVersion Include="Google.GenAI" Version="0.9.0" />
|
||||
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
|
||||
<!-- Microsoft.Azure.* -->
|
||||
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
|
||||
@@ -100,7 +100,7 @@
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5" />
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
@@ -112,14 +112,14 @@
|
||||
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
|
||||
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
|
||||
<!-- Durable Task -->
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.19.1" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.19.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.19.0" />
|
||||
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.19.0" />
|
||||
<!-- Azure Functions -->
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.13.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
|
||||
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
|
||||
|
||||
@@ -34,6 +34,13 @@
|
||||
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
|
||||
<Project Path="samples/AzureFunctions/09_Workflow/09_Workflow.csproj" />
|
||||
<Project Path="samples/AzureFunctions/10_WorkflowConcurrent/10_WorkflowConcurrent.csproj" />
|
||||
<Project Path="samples/AzureFunctions/11_WorkflowSharedState/11_WorkflowSharedState.csproj" />
|
||||
<Project Path="samples/AzureFunctions/12_ConditionalEdges/12_ConditionalEdges.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/DurableWorkflows/">
|
||||
<Project Path="samples/DurableWorkflows/01_ExecutorsAndEdges/01_ExecutorsAndEdges.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
<add key="LocalNugetSource" value="C:\LocalNugetSource" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="LocalNugetSource">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251219.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251219.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.251219.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260108.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260108.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.260108.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -8,3 +8,6 @@ dotnet_diagnostic.DURABLE0003.severity = none
|
||||
dotnet_diagnostic.DURABLE0004.severity = none
|
||||
dotnet_diagnostic.DURABLE0005.severity = none
|
||||
dotnet_diagnostic.DURABLE0006.severity = none
|
||||
|
||||
# CA1812: Internal classes are instantiated via dependency injection or reflection in samples
|
||||
dotnet_diagnostic.CA1812.severity = none
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
<AssemblyName>Workflow</AssemblyName>
|
||||
<RootNamespace>Workflow</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
POST {{authority}}/api/agents/Joker/run
|
||||
Content-Type: text/plain
|
||||
|
||||
Tell me a joke about a pirate.
|
||||
Hello world
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Parses an Order ID from a string input and returns an Order object populated.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Populate Order information from OrderId.
|
||||
return new Order(message, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches an Order object with additional information.
|
||||
/// </summary>
|
||||
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message.Customer is null)
|
||||
{
|
||||
// populate customer information for the order from database.
|
||||
message.Customer = new Customer(1, "Jerry");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcessor() : Executor<Order, Order>("ProcessPayment")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString()[^4..];
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OrderCancel() : Executor<Order, string>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return $"Order {message.Id} cancelled at {DateTime.UtcNow:g} UTC.";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Customer(int Id, string Name);
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
Func<string, string> orderParserFunc = input =>
|
||||
{
|
||||
// We accept both short ordereId(Ex:12345) and long order reference number(MSFT12345)
|
||||
// OrderId is the last 5 digigs of order reference number.
|
||||
const int OrderIdPartLength = 5;
|
||||
if (input.Length > OrderIdPartLength)
|
||||
{
|
||||
return input[^OrderIdPartLength..];
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
var orderParserExecutor = orderParserFunc.BindAsExecutor("ParseOrderId");
|
||||
|
||||
OrderLookup orderLookupExecutor = new();
|
||||
OrderEnrich orderEnricherExeecutor = new();
|
||||
PaymentProcessor paymentProcessorExecutor = new();
|
||||
|
||||
Workflow fulfillOrder = new WorkflowBuilder(orderParserExecutor)
|
||||
.WithName("FulfillOrder")
|
||||
.WithDescription("Looks up an order by ID and run payment processing")
|
||||
.AddEdge(orderParserExecutor, orderLookupExecutor)
|
||||
.AddEdge(orderLookupExecutor, orderEnricherExeecutor)
|
||||
.AddEdge(orderEnricherExeecutor, paymentProcessorExecutor)
|
||||
.Build();
|
||||
|
||||
var host = FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(fulfillOrder, enableMcpToolTrigger: true))
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Look up a long order reference id
|
||||
POST {{authority}}/api/workflows/FulfillOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
QWERTY80853
|
||||
|
||||
### Look up a short order id
|
||||
POST {{authority}}/api/workflows/CancelOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
12345
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="local.settings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
internal sealed class PrepareQuery() : Executor<string, string>("PrepareQuery")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// do some initial parsing and validation of the message.
|
||||
// Return a polished version ith additional metadta.
|
||||
if (!message.StartsWith("Query for the agent:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
message = "Query for the agent: " + message;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(message);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResultAggregator() : Executor<string[], string>("ResultAggregator")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string[] message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Aggregate all responses from parallel executors.
|
||||
string aggregatedResponse = string.Join("\n---\n", message);
|
||||
return ValueTask.FromResult($"Aggregated {message.Length} responses:\n{aggregatedResponse}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using OpenAI.Chat;
|
||||
using SingleAgent;
|
||||
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
AIAgent physicist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
|
||||
AIAgent chemist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in chemistry. You answer questions from a chemistry perspective.", "Chemist");
|
||||
|
||||
var startExecutor = new PrepareQuery();
|
||||
var aggregationExecutor = new ResultAggregator();
|
||||
|
||||
var workflow = new WorkflowBuilder(startExecutor)
|
||||
.WithName("ExpertReview")
|
||||
.AddFanOutEdge(startExecutor, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregationExecutor)
|
||||
.Build();
|
||||
|
||||
var host = FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options =>
|
||||
{
|
||||
// Configure workflows
|
||||
options.Workflows.AddWorkflow(workflow);
|
||||
})
|
||||
.Build();
|
||||
|
||||
host.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/ExpertReview/run
|
||||
Content-Type: text/plain
|
||||
|
||||
What is temperature?
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="local.settings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use durable state management in Azure Functions workflows.
|
||||
// The OrderIdParserExecutor writes a value to shared state, and the EmailSenderExecutor reads it back.
|
||||
// The state is persisted durably using Durable Entities behind the scenes.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for shared state scopes used across executors.
|
||||
/// </summary>
|
||||
internal static class SharedStateConstants
|
||||
{
|
||||
public const string MessageScope = "MessageState";
|
||||
public const string ProcessedMessageKey = "ProcessedMessage";
|
||||
}
|
||||
|
||||
public sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First executor that processes a message and stores the result in shared state.
|
||||
/// </summary>
|
||||
internal sealed class OrderIdParserExecutor() : Executor<string, Order>("OrderIdParserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Process the message
|
||||
string processedMessage = $"Processed: {message}";
|
||||
|
||||
// Store the processed message in shared state for the next executor
|
||||
await context.QueueStateUpdateAsync(
|
||||
SharedStateConstants.ProcessedMessageKey,
|
||||
processedMessage,
|
||||
SharedStateConstants.MessageScope,
|
||||
cancellationToken);
|
||||
|
||||
return GetOrder(message);
|
||||
}
|
||||
|
||||
private static Order GetOrder(string id)
|
||||
{
|
||||
// Simulate fetching order details
|
||||
return new Order(id, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor that reads the shared state and appends to the message.
|
||||
/// </summary>
|
||||
internal sealed class EmailSenderExecutor() : Executor<Order, string>("EmailSenderExecutor")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Read the processed message from shared state (written by OrderIdParserExecutor)
|
||||
string? storedMessage = await context.ReadStateAsync<string>(
|
||||
SharedStateConstants.ProcessedMessageKey,
|
||||
SharedStateConstants.MessageScope,
|
||||
cancellationToken);
|
||||
|
||||
return storedMessage is not null
|
||||
? $"From state: [{storedMessage}] | Input: [{message.Id}]"
|
||||
: $"No state found | Input: [{message.Id}]";
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcesserExecutor() : Executor<Order, Order>("PaymentProcesserExecutor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Call payment gateway.
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
|
||||
|
||||
OrderIdParserExecutor orderParser = new();
|
||||
PaymentProcesserExecutor paymentProcessor = new();
|
||||
EmailSenderExecutor emailSender = new();
|
||||
|
||||
WorkflowBuilder builder = new(orderParser);
|
||||
builder.AddEdge(orderParser, paymentProcessor);
|
||||
builder.AddEdge(paymentProcessor, emailSender).WithOutputFrom(emailSender);
|
||||
var workflow = builder.WithName("ProcessOrder").Build();
|
||||
|
||||
FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(workflow))
|
||||
.Build().Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
123
|
||||
|
||||
### Start second workflow
|
||||
POST {{authority}}/api/workflows/ProcessOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
456
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- The Functions build tools don't like namespaces that start with a number -->
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="local.settings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Azure Functions packages -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use durable state management in Azure Functions workflows.
|
||||
// The OrderIdParser writes a value to shared state, and the FraudValidation reads it back.
|
||||
// The state is persisted durably using Durable Entities behind the scenes.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SingleAgent;
|
||||
|
||||
/// <summary>
|
||||
/// Constants for shared state scopes used across executors.
|
||||
/// </summary>
|
||||
internal static class SharedStateConstants
|
||||
{
|
||||
public const string MessageScope = "MessageState";
|
||||
public const string ProcessedMessageKey = "ProcessedMessage";
|
||||
}
|
||||
|
||||
internal sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Customer(int Id, string Name, bool IsBlocked);
|
||||
|
||||
internal sealed class OrderIdParser() : Executor<string, Order>("OrderIdParser")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetOrder(message);
|
||||
}
|
||||
|
||||
private static Order GetOrder(string id)
|
||||
{
|
||||
// Simulate fetching order details
|
||||
return new Order(id, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
message.Customer = GetCustomerForOrder(message.Id);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static Customer GetCustomerForOrder(string orderId)
|
||||
{
|
||||
if (orderId.Contains('B'))
|
||||
{
|
||||
return new Customer(101, "George", true);
|
||||
}
|
||||
|
||||
return new Customer(201, "Jerry", false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcesser() : Executor<Order, Order>("PaymentProcesser")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Call payment gateway.
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NotifyFraud() : Executor<Order, string>("NotifyFraud")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Notify fraud team.
|
||||
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OrderRouteConditions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is not blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SingleAgent;
|
||||
|
||||
OrderIdParser orderParser = new();
|
||||
OrderEnrich orderEnrich = new();
|
||||
PaymentProcesser paymentProcessor = new();
|
||||
NotifyFraud notifyFraud = new();
|
||||
|
||||
WorkflowBuilder builder = new(orderParser);
|
||||
builder
|
||||
.AddEdge(orderParser, orderEnrich)
|
||||
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||
|
||||
var workflow = builder.WithName("AuditOrder").Build();
|
||||
|
||||
FunctionsApplication.CreateBuilder(args)
|
||||
.ConfigureFunctionsWebApplication()
|
||||
.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(workflow, enableMcpToolTrigger: true))
|
||||
.Build()
|
||||
.Run();
|
||||
@@ -0,0 +1,89 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the Durable Agent Framework (DAFx) to create a simple Azure Functions app that hosts a single AI agent and provides direct HTTP API access for interactive conversations.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering agents with the Function app and running them using HTTP.
|
||||
- Conversation management (via session IDs) for isolated interactions.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup and function app running, you can test the sample by sending an HTTP request to the agent endpoint.
|
||||
|
||||
You can use the `demo.http` file to send a message to the agent, or a command line tool like `curl` as shown below:
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method Post `
|
||||
-Uri http://localhost:7071/api/agents/Joker/run `
|
||||
-ContentType text/plain `
|
||||
-Body "Tell me a joke about a pirate."
|
||||
```
|
||||
|
||||
You can also send JSON requests:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:7071/api/agents/Joker/run \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me a joke about a pirate."}'
|
||||
```
|
||||
|
||||
To continue a conversation, include the `thread_id` in the query string or JSON body:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:7071/api/agents/Joker/run?thread_id=your-thread-id" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"message": "Tell me another one."}'
|
||||
```
|
||||
|
||||
The response from the agent will be displayed in the terminal where you ran `func start`. The expected `text/plain` output will look something like:
|
||||
|
||||
```text
|
||||
Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
```
|
||||
|
||||
The expected `application/json` output will look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"thread_id": "ee6e47a0-f24b-40b1-ade8-16fcebb9eb40",
|
||||
"response": {
|
||||
"Messages": [
|
||||
{
|
||||
"AuthorName": "Joker",
|
||||
"CreatedAt": "2025-11-11T12:00:00.0000000Z",
|
||||
"Role": "assistant",
|
||||
"Contents": [
|
||||
{
|
||||
"Type": "text",
|
||||
"Text": "Why don't pirates ever learn the alphabet? Because they always get stuck at 'C'!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Usage": {
|
||||
"InputTokenCount": 78,
|
||||
"OutputTokenCount": 36,
|
||||
"TotalTokenCount": 114
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
# Default endpoint address for local testing
|
||||
@authority=http://localhost:7071
|
||||
|
||||
### Start the workflow
|
||||
POST {{authority}}/api/workflows/AuditOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
B123
|
||||
|
||||
### Start second workflow
|
||||
POST {{authority}}/api/workflows/AuditOrder/run
|
||||
Content-Type: text/plain
|
||||
|
||||
456
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "2.0",
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"Microsoft.Agents.AI.DurableTask": "Information",
|
||||
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
|
||||
"DurableTask": "Information",
|
||||
"Microsoft.DurableTask": "Information"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"durableTask": {
|
||||
"hubName": "default",
|
||||
"storageProvider": {
|
||||
"type": "AzureManaged",
|
||||
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowExecutorsAndEdgesSample;
|
||||
|
||||
/// <summary>
|
||||
/// This sample introduces the concepts of executors and edges in a workflow.
|
||||
///
|
||||
/// Workflows are built from executors (processing units) connected by edges (data flow paths).
|
||||
/// In this example, we create a simple text processing pipeline that:
|
||||
/// 1. Takes input text and converts it to uppercase using an UppercaseExecutor
|
||||
/// 2. Takes the uppercase text and reverses it using a ReverseTextExecutor
|
||||
///
|
||||
/// The executors are connected sequentially, so data flows from one to the next in order.
|
||||
/// For input "Hello, World!", the workflow produces "!DLROW ,OLLEH".
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private static async Task Main()
|
||||
{
|
||||
// Create the executors
|
||||
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
|
||||
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
|
||||
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
// Build the workflow by connecting executors sequentially
|
||||
WorkflowBuilder builder = new(uppercase);
|
||||
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
|
||||
var workflow = builder.Build();
|
||||
|
||||
// Execute the workflow with input data
|
||||
await using Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
|
||||
foreach (WorkflowEvent evt in run.NewEvents)
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorComplete)
|
||||
{
|
||||
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Second executor: reverses the input text and completes the workflow.
|
||||
/// </summary>
|
||||
internal sealed class ReverseTextExecutor() : Executor<string, string>("ReverseTextExecutor")
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the input message by reversing the text.
|
||||
/// </summary>
|
||||
/// <param name="message">The input text to reverse</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 reversed</returns>
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
|
||||
return ValueTask.FromResult(string.Concat(message.Reverse()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using OpenAI.Chat;
|
||||
|
||||
namespace WorkflowVisualizationSample;
|
||||
|
||||
@@ -20,8 +25,29 @@ internal static class Program
|
||||
/// <param name="args">Command line arguments (not used).</param>
|
||||
private static void Main(string[] args)
|
||||
{
|
||||
// Step 1: Build the workflow you want to visualize
|
||||
Workflow workflow = WorkflowMapReduceSample.Program.BuildWorkflow();
|
||||
// Get the Azure OpenAI endpoint and deployment name from environment variables.
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
AIAgent physicist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in physics. You answer questions from a physics perspective.", "Physicist");
|
||||
AIAgent chemist = client.GetChatClient(deploymentName).CreateAIAgent("You are an expert in chemistry. You answer questions from a chemistry perspective.", "Chemist");
|
||||
|
||||
var startExecutor = new PrepareQuery();
|
||||
var aggregationExecutor = new ResultAggregator();
|
||||
|
||||
var workflow = new WorkflowBuilder(startExecutor)
|
||||
.WithName("ExpertReview")
|
||||
.AddFanOutEdge(startExecutor, [physicist, chemist])
|
||||
.AddFanInEdge([physicist, chemist], aggregationExecutor)
|
||||
.Build();
|
||||
|
||||
// Step 2: Generate and display workflow visualization
|
||||
Console.WriteLine("Generating workflow visualization...");
|
||||
@@ -31,11 +57,30 @@ internal static class Program
|
||||
var mermaid = workflow.ToMermaidString();
|
||||
Console.WriteLine(mermaid);
|
||||
Console.WriteLine("=======");
|
||||
}
|
||||
}
|
||||
|
||||
// DOT
|
||||
Console.WriteLine("DiGraph string: *** Tip: To export DOT as an image, install Graphviz and pipe the DOT output to 'dot -Tsvg', 'dot -Tpng', etc. *** \n=======");
|
||||
var dotString = workflow.ToDotString();
|
||||
Console.WriteLine(dotString);
|
||||
Console.WriteLine("=======");
|
||||
internal sealed class PrepareQuery() : Executor<string, string>("PrepareQuery")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// do some initial parsing and validation of the message.
|
||||
// Return a polished version ith additional metadta.
|
||||
if (!message.StartsWith("Query for the agent:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
message = "Query for the agent: " + message;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult(message);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResultAggregator() : Executor<string[], string>("ResultAggregator")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(string[] message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Aggregate all responses from parallel executors.
|
||||
string aggregatedResponse = string.Join("\n---\n", message);
|
||||
return ValueTask.FromResult($"Aggregated {message.Length} responses:\n{aggregatedResponse}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\Concurrent\MapReduce\MapReduce.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -9,7 +9,7 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
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";
|
||||
@@ -25,7 +25,7 @@ AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAd
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.GetResponsesClient(deploymentName)
|
||||
.CreateAIAgent(
|
||||
instructions: "You answer questions by searching the Microsoft Learn content only.",
|
||||
name: "MicrosoftLearnAgent",
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Add analyzers with compatible versions -->
|
||||
|
||||
@@ -8,9 +8,8 @@ using Azure.AI.AgentServer.AgentFramework.Extensions;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Data;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
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";
|
||||
@@ -28,7 +27,10 @@ AIAgent agent = new 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.",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
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 => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.17.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI.A2A;
|
||||
/// Support for tasks will be added later as part of the long-running
|
||||
/// executions work.
|
||||
/// </remarks>
|
||||
internal sealed class A2AAgent : AIAgent
|
||||
public sealed class A2AAgent : AIAgent
|
||||
{
|
||||
private readonly A2AClient _a2aClient;
|
||||
private readonly string? _id;
|
||||
@@ -84,9 +84,13 @@ internal sealed class A2AAgent : AIAgent
|
||||
}
|
||||
else
|
||||
{
|
||||
var a2aMessage = CreateA2AMessage(typedThread, messages);
|
||||
MessageSendParams sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedThread, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
|
||||
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
|
||||
@@ -154,9 +158,13 @@ internal sealed class A2AAgent : AIAgent
|
||||
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var a2aMessage = CreateA2AMessage(typedThread, messages);
|
||||
MessageSendParams sendParams = new()
|
||||
{
|
||||
Message = CreateA2AMessage(typedThread, messages),
|
||||
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
|
||||
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
|
||||
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
|
||||
|
||||
@@ -198,10 +206,10 @@ internal sealed class A2AAgent : AIAgent
|
||||
protected override string? IdCore => this._id;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Name => this._name ?? base.Name;
|
||||
public override string? Name => this._name;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? Description => this._description ?? base.Description;
|
||||
public override string? Description => this._description;
|
||||
|
||||
private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,9 @@ internal static class A2AMetadataExtensions
|
||||
/// <summary>
|
||||
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be replaced by the one from A2A SDK once it is public.
|
||||
/// </remarks>
|
||||
/// <param name="metadata">The metadata dictionary to convert.</param>
|
||||
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
|
||||
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
namespace Microsoft.Extensions.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for AdditionalPropertiesDictionary.
|
||||
/// </summary>
|
||||
internal static class AdditionalPropertiesDictionaryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be replaced by the one from A2A SDK once it is available.
|
||||
/// </remarks>
|
||||
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
|
||||
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
|
||||
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
|
||||
{
|
||||
if (additionalProperties is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, JsonElement>();
|
||||
|
||||
foreach (var kvp in additionalProperties)
|
||||
{
|
||||
if (kvp.Value is JsonElement)
|
||||
{
|
||||
metadata[kvp.Key] = (JsonElement)kvp.Value!;
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -216,8 +216,7 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
|
||||
|
||||
private AIAgent GetAgent(AgentSessionId sessionId)
|
||||
{
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
|
||||
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = this._options.GetAgentFactories();
|
||||
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
|
||||
{
|
||||
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
|
||||
|
||||
@@ -10,8 +10,12 @@ public sealed class DurableAgentsOptions
|
||||
// Agent names are case-insensitive
|
||||
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _workflowOnlyAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal DurableAgentsOptions()
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableAgentsOptions"/> class.
|
||||
/// </summary>
|
||||
public DurableAgentsOptions()
|
||||
{
|
||||
}
|
||||
|
||||
@@ -101,6 +105,22 @@ public sealed class DurableAgentsOptions
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
|
||||
{
|
||||
return this.AddAIAgent(agent, workflowOnly: false, timeToLive);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an AI agent to the options with workflow-only configuration.
|
||||
/// </summary>
|
||||
/// <param name="agent">The agent to add.</param>
|
||||
/// <param name="workflowOnly">If true, the agent is only accessible within workflows and won't have HTTP triggers.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
|
||||
/// <returns>The options instance.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
|
||||
/// </exception>
|
||||
public DurableAgentsOptions AddAIAgent(AIAgent agent, bool workflowOnly, TimeSpan? timeToLive = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
@@ -120,6 +140,11 @@ public sealed class DurableAgentsOptions
|
||||
this._agentTimeToLive[agent.Name] = timeToLive;
|
||||
}
|
||||
|
||||
if (workflowOnly)
|
||||
{
|
||||
this._workflowOnlyAgents.Add(agent.Name);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -141,4 +166,24 @@ public sealed class DurableAgentsOptions
|
||||
{
|
||||
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an agent is configured as workflow-only (no HTTP triggers).
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns><see langword="true"/> if the agent is workflow-only; otherwise, <see langword="false"/>.</returns>
|
||||
internal bool IsWorkflowOnly(string agentName)
|
||||
{
|
||||
return this._workflowOnlyAgents.Contains(agentName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an agent with the specified name is already registered.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent.</param>
|
||||
/// <returns><see langword="true"/> if an agent with the name is registered; otherwise, <see langword="false"/>.</returns>
|
||||
internal bool ContainsAgent(string agentName)
|
||||
{
|
||||
return this._agentFactories.ContainsKey(agentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.Entities;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// An implementation of <see cref="IWorkflowContext"/> for workflow executors running as durable activities.
|
||||
/// Provides durable state management using Durable Entities. State is scoped to the orchestration instance
|
||||
/// and shared between executors running on potentially different compute instances.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// State operations use GetEntityAsync for reads (fetches current entity state) and SignalEntityAsync
|
||||
/// for writes. Since activities run sequentially in the orchestration and entity signals are processed
|
||||
/// in order, state consistency is maintained across executors.
|
||||
/// </remarks>
|
||||
[RequiresUnreferencedCode("State serialization uses reflection-based JSON serialization.")]
|
||||
[RequiresDynamicCode("State serialization uses reflection-based JSON serialization.")]
|
||||
public sealed class DurableExecutorContext : IWorkflowContext
|
||||
{
|
||||
private readonly string _instanceId;
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly Dictionary<string, string?> _pendingUpdates = [];
|
||||
private readonly HashSet<string> _clearedScopes = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableExecutorContext"/> class.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The orchestration instance ID used to scope the state entity.</param>
|
||||
/// <param name="client">The durable task client for entity operations.</param>
|
||||
public DurableExecutorContext(string instanceId, DurableTaskClient client)
|
||||
{
|
||||
this._instanceId = instanceId;
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask AddEventAsync(WorkflowEvent workflowEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In activity context, events are not propagated to the workflow
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In activity context, messages cannot be routed to other executors
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In activity context, outputs are not yielded to the workflow
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask RequestHaltAsync()
|
||||
{
|
||||
// Halt requests are not supported in activity context
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<T?> ReadStateAsync<T>(string key, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
|
||||
// 1. Check pending updates first (read-your-writes within this activity)
|
||||
if (this._pendingUpdates.TryGetValue(scopeKey, out string? pendingValue))
|
||||
{
|
||||
return pendingValue is null ? default : JsonSerializer.Deserialize<T>(pendingValue);
|
||||
}
|
||||
|
||||
// 2. Check if the scope was cleared in this activity
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
if (this._clearedScopes.Contains(normalizedScope))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// 3. Read from the durable entity
|
||||
EntityInstanceId entityId = this.GetStateEntityId();
|
||||
|
||||
EntityMetadata? metadata = await this._client.Entities
|
||||
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (metadata?.IncludesState != true)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
|
||||
if (stateData?.Values is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (stateData.Values.TryGetValue(scopeKey, out string? serializedValue) && serializedValue is not null)
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(serializedValue);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<T> ReadOrInitStateAsync<T>(string key, Func<T> initialStateFactory, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
// Initialize with factory value and write to entity
|
||||
T initialValue = initialStateFactory();
|
||||
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
|
||||
return initialValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<HashSet<string>> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
HashSet<string> keys = [];
|
||||
|
||||
// If scope was cleared, only return keys from pending updates
|
||||
if (this._clearedScopes.Contains(normalizedScope))
|
||||
{
|
||||
return this.GetPendingKeysForScope(scopeName);
|
||||
}
|
||||
|
||||
// Read keys from the durable entity
|
||||
EntityInstanceId entityId = this.GetStateEntityId();
|
||||
|
||||
EntityMetadata? metadata = await this._client.Entities
|
||||
.GetEntityAsync(entityId, includeState: true, cancellation: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (metadata?.IncludesState == true)
|
||||
{
|
||||
WorkflowStateData? stateData = metadata.State.ReadAs<WorkflowStateData>();
|
||||
if (stateData?.Values is not null)
|
||||
{
|
||||
foreach (string scopeKey in stateData.Values.Keys)
|
||||
{
|
||||
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
string foundKey = scopeKey[scopePrefix.Length..];
|
||||
keys.Add(foundKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with pending updates
|
||||
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
|
||||
{
|
||||
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
string foundKey = pending.Key[scopePrefix.Length..];
|
||||
if (pending.Value is not null)
|
||||
{
|
||||
keys.Add(foundKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
keys.Remove(foundKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask QueueStateUpdateAsync<T>(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string scopeKey = GetScopeKey(scopeName, key);
|
||||
string? serializedValue = value is null ? null : JsonSerializer.Serialize(value);
|
||||
|
||||
// Store locally for read-your-writes within this activity
|
||||
this._pendingUpdates[scopeKey] = serializedValue;
|
||||
|
||||
// Write to the durable entity via signal
|
||||
// Since activities run sequentially and signals are processed in order,
|
||||
// the next activity will see this update when it reads from the entity
|
||||
EntityInstanceId entityId = this.GetStateEntityId();
|
||||
WorkflowStateWriteRequest request = new() { Key = key, ScopeName = scopeName, Value = serializedValue };
|
||||
|
||||
await this._client.Entities
|
||||
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.WriteState), request, cancellation: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string normalizedScope = scopeName ?? "__default__";
|
||||
this._clearedScopes.Add(normalizedScope);
|
||||
|
||||
// Remove pending updates in this scope
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
List<string> keysToRemove = this._pendingUpdates.Keys
|
||||
.Where(k => k.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
foreach (string key in keysToRemove)
|
||||
{
|
||||
this._pendingUpdates.Remove(key);
|
||||
}
|
||||
|
||||
// Clear in the durable entity via signal
|
||||
EntityInstanceId entityId = this.GetStateEntityId();
|
||||
|
||||
await this._client.Entities
|
||||
.SignalEntityAsync(entityId, nameof(WorkflowSharedStateEntity.ClearScope), scopeName, cancellation: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string>? TraceContext => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool ConcurrentRunsEnabled => false;
|
||||
|
||||
private EntityInstanceId GetStateEntityId()
|
||||
{
|
||||
// Entity is keyed by orchestration instance ID for isolation between runs
|
||||
return new EntityInstanceId(WorkflowSharedStateEntity.EntityName, this._instanceId);
|
||||
}
|
||||
|
||||
private HashSet<string> GetPendingKeysForScope(string? scopeName)
|
||||
{
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
HashSet<string> keys = [];
|
||||
|
||||
foreach (KeyValuePair<string, string?> pending in this._pendingUpdates)
|
||||
{
|
||||
if (pending.Key.StartsWith(scopePrefix, StringComparison.Ordinal) && pending.Value is not null)
|
||||
{
|
||||
string key = pending.Key[scopePrefix.Length..];
|
||||
keys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static string GetScopeKey(string? scopeName, string key)
|
||||
{
|
||||
return $"{GetScopePrefix(scopeName)}{key}";
|
||||
}
|
||||
|
||||
private static string GetScopePrefix(string? scopeName)
|
||||
{
|
||||
return scopeName is null ? "__default__:" : $"{scopeName}:";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for durable agents and workflows.
|
||||
/// </summary>
|
||||
public sealed class DurableOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the configuration options for durable agents.
|
||||
/// </summary>
|
||||
public DurableAgentsOptions Agents { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configuration options for durable workflows.
|
||||
/// </summary>
|
||||
public DurableWorkflowOptions Workflows { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
|
||||
/// </summary>
|
||||
internal DurableOptions()
|
||||
{
|
||||
this.Workflows = new DurableWorkflowOptions(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for managing durable workflows within an application.
|
||||
/// </summary>
|
||||
public sealed class DurableWorkflowOptions
|
||||
{
|
||||
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly DurableOptions? _parentOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
|
||||
/// </summary>
|
||||
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
|
||||
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
|
||||
{
|
||||
this._parentOptions = parentOptions;
|
||||
this.Executors = new ExecutorRegistry();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of workflows available in the current context, keyed by their unique names.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the executor registry for direct executor lookup.
|
||||
/// </summary>
|
||||
internal ExecutorRegistry Executors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow to the collection for processing or execution.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
|
||||
/// <remarks>
|
||||
/// When a workflow is added, any AI agent executors in the workflow will be automatically
|
||||
/// registered with the <see cref="DurableAgentsOptions"/> if it was provided during construction.
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public void AddWorkflow(Workflow workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
this._workflows[workflow.Name] = workflow;
|
||||
|
||||
RegisterExecutors(workflow, this.Executors);
|
||||
|
||||
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
|
||||
if (agentOptions is not null)
|
||||
{
|
||||
RegisterAgenticExecutors(workflow, agentOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a collection of workflows to the current instance.
|
||||
/// </summary>
|
||||
/// <param name="workflows">The collection of <see cref="Workflow"/> objects to add. Cannot be <see langword="null"/>.</param>
|
||||
public void AddWorkflows(IEnumerable<Workflow> workflows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflows);
|
||||
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
this.AddWorkflow(workflow);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterExecutors(Workflow workflow, ExecutorRegistry registry)
|
||||
{
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in workflow.ReflectExecutors())
|
||||
{
|
||||
int underscoreIndex = executor.Key.IndexOf('_');
|
||||
string executorName = underscoreIndex > 0 ? executor.Key[..underscoreIndex] : executor.Key;
|
||||
registry.Register(executorName, executor.Key, workflow);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterAgenticExecutors(Workflow workflow, DurableAgentsOptions agentOptions)
|
||||
{
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in workflow.ReflectExecutors())
|
||||
{
|
||||
if (executor.Value.RawValue is AIAgent agent && agent.Name is not null && !agentOptions.ContainsAgent(agent.Name))
|
||||
{
|
||||
agentOptions.AddAIAgent(agent, workflowOnly: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Core workflow runner that executes workflow orchestrations using Durable Tasks.
|
||||
/// This class contains the core workflow execution logic independent of the hosting environment.
|
||||
/// </summary>
|
||||
public class DurableWorkflowRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DurableWorkflowRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||
public DurableWorkflowRunner(ILogger<DurableWorkflowRunner> logger, DurableOptions durableOptions)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||
|
||||
this.Logger = logger;
|
||||
this.Options = durableOptions.Workflows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the workflow options.
|
||||
/// </summary>
|
||||
protected DurableWorkflowOptions Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the logger instance.
|
||||
/// </summary>
|
||||
protected ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow orchestration.
|
||||
/// </summary>
|
||||
/// <param name="context">The task orchestration context.</param>
|
||||
/// <param name="input">The workflow run input containing workflow name and input.</param>
|
||||
/// <param name="logger">The replay-safe logger for orchestration logging.</param>
|
||||
/// <returns>The result of the workflow execution.</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when the specified workflow is not found.</exception>
|
||||
public async Task<string> RunWorkflowOrchestrationAsync(
|
||||
TaskOrchestrationContext context,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
string orchestrationName = context.Name;
|
||||
string workflowName = WorkflowNamingHelper.ToWorkflowName(orchestrationName);
|
||||
if (!this.Options.Workflows.TryGetValue(workflowName, out Workflow? workflow))
|
||||
{
|
||||
throw new InvalidOperationException($"Workflow '{workflowName}' not found.");
|
||||
}
|
||||
|
||||
logger.LogRunningWorkflow(workflow.Name);
|
||||
|
||||
string result = await this.ExecuteWorkflowLevelsAsync(context, workflow, input, logger).ConfigureAwait(true);
|
||||
|
||||
await CleanupWorkflowStateAsync(context).ConfigureAwait(true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up the workflow state entity by signaling it to delete itself.
|
||||
/// </summary>
|
||||
private static async Task CleanupWorkflowStateAsync(TaskOrchestrationContext context)
|
||||
{
|
||||
EntityInstanceId stateEntityId = new(WorkflowSharedStateEntity.EntityName, context.InstanceId);
|
||||
|
||||
// Call the entity's Delete method to clean up state
|
||||
// Using CallEntityAsync ensures the deletion completes before the orchestration finishes
|
||||
await context.Entities.CallEntityAsync(stateEntityId, nameof(WorkflowSharedStateEntity.Delete)).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the executor name from an activity function name.
|
||||
/// </summary>
|
||||
/// <param name="activityFunctionName">The activity function name.</param>
|
||||
/// <returns>The extracted executor name.</returns>
|
||||
protected static string ParseExecutorName(string activityFunctionName)
|
||||
{
|
||||
if (!activityFunctionName.StartsWith(WorkflowNamingHelper.OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' does not start with '{WorkflowNamingHelper.OrchestrationFunctionPrefix}' prefix.");
|
||||
}
|
||||
|
||||
string executorName = activityFunctionName[WorkflowNamingHelper.OrchestrationFunctionPrefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(executorName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Activity function name '{activityFunctionName}' is not in the expected format '{WorkflowNamingHelper.OrchestrationFunctionPrefix}{{executorName}}'.");
|
||||
}
|
||||
|
||||
return executorName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a list of strings to JSON.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing known types.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing known types.")]
|
||||
protected static string SerializeToJson(List<string> values)
|
||||
{
|
||||
return JsonSerializer.Serialize(values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a result object to JSON or string.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types registered at startup.")]
|
||||
protected static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (result is string str)
|
||||
{
|
||||
return str;
|
||||
}
|
||||
|
||||
Type resultType = result.GetType();
|
||||
if (resultType.IsPrimitive || resultType == typeof(decimal))
|
||||
{
|
||||
return result.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(result, resultType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes input from JSON to the target type.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||
protected static object DeserializeInput(string input, Type targetType)
|
||||
{
|
||||
if (targetType == typeof(string))
|
||||
{
|
||||
return input;
|
||||
}
|
||||
|
||||
string json = input;
|
||||
if (input.StartsWith('"') && input.EndsWith('"'))
|
||||
{
|
||||
try
|
||||
{
|
||||
string? innerJson = JsonSerializer.Deserialize<string>(input);
|
||||
if (innerJson is not null)
|
||||
{
|
||||
json = innerJson;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not double-serialized, use original
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, targetType)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteWorkflowLevelsAsync(
|
||||
TaskOrchestrationContext context,
|
||||
Workflow workflow,
|
||||
string initialInput,
|
||||
ILogger logger)
|
||||
{
|
||||
WorkflowExecutionPlan plan = WorkflowHelper.GetExecutionPlan(workflow);
|
||||
Dictionary<string, string> results = [];
|
||||
|
||||
foreach (WorkflowExecutionLevel level in plan.Levels)
|
||||
{
|
||||
// Filter executors based on edge conditions from their predecessors
|
||||
List<WorkflowExecutorInfo> eligibleExecutors = GetEligibleExecutors(level.Executors, results, plan, logger);
|
||||
|
||||
if (eligibleExecutors.Count == 0)
|
||||
{
|
||||
// No eligible executors at this level, continue to next level
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eligibleExecutors.Count == 1)
|
||||
{
|
||||
WorkflowExecutorInfo executorInfo = eligibleExecutors[0];
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
results[executorInfo.ExecutorId] = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
List<Task<(string Id, string Result)>> tasks = [];
|
||||
foreach (WorkflowExecutorInfo executorInfo in eligibleExecutors)
|
||||
{
|
||||
string input = GetExecutorInput(executorInfo.ExecutorId, initialInput, results, plan);
|
||||
tasks.Add(this.ExecuteExecutorWithIdAsync(context, executorInfo, input, logger));
|
||||
}
|
||||
|
||||
foreach ((string id, string result) in await Task.WhenAll(tasks).ConfigureAwait(true))
|
||||
{
|
||||
results[id] = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return GetFinalResult(plan, results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters executors based on their incoming edge conditions.
|
||||
/// An executor is eligible if all its incoming edges have conditions that evaluate to true,
|
||||
/// or if the edges have no conditions.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||
private static List<WorkflowExecutorInfo> GetEligibleExecutors(
|
||||
List<WorkflowExecutorInfo> executors,
|
||||
Dictionary<string, string> results,
|
||||
WorkflowExecutionPlan plan,
|
||||
ILogger logger)
|
||||
{
|
||||
List<WorkflowExecutorInfo> eligible = [];
|
||||
|
||||
foreach (WorkflowExecutorInfo executorInfo in executors)
|
||||
{
|
||||
List<string> predecessors = plan.Predecessors[executorInfo.ExecutorId];
|
||||
|
||||
// Root executor (no predecessors) is always eligible
|
||||
if (predecessors.Count == 0)
|
||||
{
|
||||
eligible.Add(executorInfo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any predecessor's edge condition allows this executor to run
|
||||
bool isEligible = false;
|
||||
foreach (string predecessorId in predecessors)
|
||||
{
|
||||
// Get the condition for this edge (predecessor -> current executor)
|
||||
if (!plan.EdgeConditions.TryGetValue((predecessorId, executorInfo.ExecutorId), out Func<object?, bool>? condition))
|
||||
{
|
||||
// No condition registered for this edge, assume it's eligible
|
||||
isEligible = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (condition is null)
|
||||
{
|
||||
// Edge has no condition, always eligible
|
||||
isEligible = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Evaluate the condition using the predecessor's result
|
||||
if (results.TryGetValue(predecessorId, out string? predecessorResult))
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the predecessor's output type for proper deserialization
|
||||
Type? predecessorOutputType = plan.ExecutorOutputTypes.GetValueOrDefault(predecessorId);
|
||||
|
||||
// Deserialize the predecessor result to the expected type for condition evaluation
|
||||
object? resultObject = DeserializeForCondition(predecessorResult, predecessorOutputType);
|
||||
if (condition(resultObject))
|
||||
{
|
||||
isEligible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to evaluate condition for edge from '{PredecessorId}' to '{ExecutorId}'", predecessorId, executorInfo.ExecutorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEligible)
|
||||
{
|
||||
eligible.Add(executorInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogExecutorSkipped(executorInfo.ExecutorId);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes a JSON string result into an object for condition evaluation.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
|
||||
private static object? DeserializeForCondition(string json, Type? targetType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (targetType is null)
|
||||
{
|
||||
return JsonSerializer.Deserialize<object>(json);
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(json, targetType);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// If it's not valid JSON, return the string as-is
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string Id, string Result)> ExecuteExecutorWithIdAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string result = await this.ExecuteExecutorAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
return (executorInfo.ExecutorId, result);
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteExecutorAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
if (!executorInfo.IsAgenticExecutor)
|
||||
{
|
||||
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
|
||||
string triggerName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||
return await context.CallActivityAsync<string>(triggerName, input).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return await ExecuteAgentAsync(context, executorInfo, input, logger).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private static async Task<string> ExecuteAgentAsync(
|
||||
TaskOrchestrationContext context,
|
||||
WorkflowExecutorInfo executorInfo,
|
||||
string input,
|
||||
ILogger logger)
|
||||
{
|
||||
string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
|
||||
DurableAIAgent agent = context.GetAgent(agentName);
|
||||
|
||||
if (agent is null)
|
||||
{
|
||||
logger.LogWarning("Agent '{AgentName}' not found", agentName);
|
||||
return $"Agent '{agentName}' not found";
|
||||
}
|
||||
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
AgentRunResponse response = await agent.RunAsync(input, thread).ConfigureAwait(true);
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
private static string GetExecutorInput(
|
||||
string executorId,
|
||||
string initialInput,
|
||||
Dictionary<string, string> results,
|
||||
WorkflowExecutionPlan plan)
|
||||
{
|
||||
List<string> predecessors = plan.Predecessors[executorId];
|
||||
|
||||
if (predecessors.Count == 0)
|
||||
{
|
||||
return initialInput;
|
||||
}
|
||||
|
||||
if (predecessors.Count == 1)
|
||||
{
|
||||
return results.TryGetValue(predecessors[0], out string? result) ? result : initialInput;
|
||||
}
|
||||
|
||||
List<string> aggregated = [];
|
||||
foreach (string predecessorId in predecessors)
|
||||
{
|
||||
if (results.TryGetValue(predecessorId, out string? result))
|
||||
{
|
||||
aggregated.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return SerializeToJson(aggregated);
|
||||
}
|
||||
|
||||
private static string GetFinalResult(WorkflowExecutionPlan plan, Dictionary<string, string> results)
|
||||
{
|
||||
WorkflowExecutionLevel lastLevel = plan.Levels[^1];
|
||||
|
||||
if (lastLevel.Executors.Count == 1)
|
||||
{
|
||||
return results[lastLevel.Executors[0].ExecutorId];
|
||||
}
|
||||
|
||||
List<string> finalResults = [];
|
||||
foreach (WorkflowExecutorInfo executor in lastLevel.Executors)
|
||||
{
|
||||
if (results.TryGetValue(executor.ExecutorId, out string? result))
|
||||
{
|
||||
finalResults.Add(result);
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join("\n---\n", finalResults);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a registry for storing and retrieving executor bindings independently from workflows.
|
||||
/// </summary>
|
||||
internal sealed class ExecutorRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ExecutorRegistration> _executors = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the number of registered executors.
|
||||
/// </summary>
|
||||
public int Count => this._executors.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get an executor registration by name.
|
||||
/// </summary>
|
||||
/// <param name="executorName">The executor name to look up.</param>
|
||||
/// <param name="registration">When this method returns, contains the registration if found; otherwise, null.</param>
|
||||
/// <returns><see langword="true"/> if the executor was found; otherwise, <see langword="false"/>.</returns>
|
||||
public bool TryGetExecutor(string executorName, out ExecutorRegistration? registration)
|
||||
{
|
||||
return this._executors.TryGetValue(executorName, out registration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an executor binding from a workflow.
|
||||
/// </summary>
|
||||
/// <param name="executorName">The executor name (without GUID suffix).</param>
|
||||
/// <param name="executorId">The full executor ID (may include GUID suffix).</param>
|
||||
/// <param name="workflow">The workflow containing the executor.</param>
|
||||
internal void Register(string executorName, string executorId, Workflow workflow)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorName);
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorBinding> bindings = workflow.ReflectExecutors();
|
||||
if (!bindings.TryGetValue(executorId, out ExecutorBinding? binding))
|
||||
{
|
||||
throw new InvalidOperationException($"Executor '{executorId}' not found in workflow.");
|
||||
}
|
||||
|
||||
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a registered executor with its associated workflow.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorId">The full executor ID (may include GUID suffix).</param>
|
||||
/// <param name="Binding">The executor binding from the workflow.</param>
|
||||
internal sealed record ExecutorRegistration(string ExecutorId, ExecutorBinding Binding)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an instance of the executor.
|
||||
/// </summary>
|
||||
/// <param name="runId">A unique identifier for the run context.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The created executor instance.</returns>
|
||||
public async ValueTask<Executor> CreateExecutorInstanceAsync(string runId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this.Binding.FactoryAsync is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Cannot create executor '{this.ExecutorId}': Binding is a placeholder.");
|
||||
}
|
||||
|
||||
return await this.Binding.FactoryAsync(runId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -100,4 +100,40 @@ internal static partial class Logs
|
||||
public static partial void LogTTLExpirationTimeCleared(
|
||||
this ILogger logger,
|
||||
AgentSessionId sessionId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 12,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Attempting to run workflow: {WorkflowName}")]
|
||||
public static partial void LogAttemptingToRunWorkflow(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 13,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Running workflow: {WorkflowName}")]
|
||||
public static partial void LogRunningWorkflow(this ILogger logger, string? workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 14,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Attempting to execute activity in workflow '{WorkflowName}' for executor '{ExecutorName}'")]
|
||||
public static partial void LogAttemptingToExecuteActivity(this ILogger logger, string workflowName, string executorName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 15,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Executing activity for executor '{ExecutorId}' of type '{ExecutorType}'")]
|
||||
public static partial void LogExecutingActivity(this ILogger logger, string executorId, string executorType);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 16,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Activity executed for executor '{ExecutorId}' with result: {Result}")]
|
||||
public static partial void LogActivityExecuted(this ILogger logger, string executorId, string result);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 17,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Executor '{ExecutorId}' skipped due to edge condition evaluation")]
|
||||
public static partial void LogExecutorSkipped(this ILogger logger, string executorId);
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.DurableTask.Client" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
@@ -80,23 +81,45 @@ public static class ServiceCollectionExtensions
|
||||
DurableAgentsOptions options = new();
|
||||
configure(options);
|
||||
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> newAgents = options.GetAgentFactories();
|
||||
|
||||
// The agent dictionary contains the real agent factories, which is used by the agent entities.
|
||||
services.AddSingleton(agents);
|
||||
// Check if we already have DurableAgentsOptions registered and merge with it
|
||||
ServiceDescriptor? existingOptionsDescriptor = services.FirstOrDefault(
|
||||
d => d.ServiceType == typeof(DurableAgentsOptions));
|
||||
|
||||
// Register the options so AgentEntity can access TTL configuration
|
||||
services.AddSingleton(options);
|
||||
if (existingOptionsDescriptor?.ImplementationInstance is DurableAgentsOptions existingOptions)
|
||||
{
|
||||
// Merge new agents into the existing options
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agent in newAgents)
|
||||
{
|
||||
if (!existingOptions.ContainsAgent(agent.Key))
|
||||
{
|
||||
existingOptions.AddAIAgentFactory(agent.Key, agent.Value, options.GetTimeToLive(agent.Key));
|
||||
}
|
||||
}
|
||||
|
||||
options = existingOptions;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Register the options so AgentEntity can access configuration
|
||||
services.AddSingleton(options);
|
||||
}
|
||||
|
||||
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
|
||||
foreach (var factory in agents)
|
||||
foreach (var factory in newAgents)
|
||||
{
|
||||
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
|
||||
}
|
||||
|
||||
// Register the agent factories dictionary for backward compatibility.
|
||||
// This allows consumers to retrieve agents via services.GetService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>().
|
||||
services.TryAddSingleton(
|
||||
sp => sp.GetRequiredService<DurableAgentsOptions>().GetAgentFactories());
|
||||
|
||||
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
|
||||
// which is not the default behavior for the Durable Task SDK.
|
||||
services.AddSingleton<DataConverter, DefaultDataConverter>();
|
||||
services.TryAddSingleton<DataConverter, DefaultDataConverter>();
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an executor in the workflow with its metadata.
|
||||
/// </summary>
|
||||
/// <param name="ExecutorId">The unique identifier of the executor.</param>
|
||||
/// <param name="IsAgenticExecutor">Indicates whether this executor is an agentic executor.</param>
|
||||
public sealed record WorkflowExecutorInfo(string ExecutorId, bool IsAgenticExecutor);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a level of executors that can be executed in parallel (Fan-Out).
|
||||
/// All executors in the same level have their dependencies satisfied by previous levels.
|
||||
/// </summary>
|
||||
/// <param name="Level">The level number (0-based, starting from the root executor).</param>
|
||||
/// <param name="Executors">The executors that can run in parallel at this level.</param>
|
||||
/// <param name="IsFanIn">Indicates if this level is a Fan-In point (has executors with multiple predecessors).</param>
|
||||
public sealed record WorkflowExecutionLevel(int Level, List<WorkflowExecutorInfo> Executors, bool IsFanIn);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the complete execution plan for a workflow, including parallel execution levels.
|
||||
/// </summary>
|
||||
public sealed class WorkflowExecutionPlan
|
||||
{
|
||||
/// <summary>
|
||||
/// The execution levels in order. Each level contains executors that can run in parallel.
|
||||
/// </summary>
|
||||
public List<WorkflowExecutionLevel> Levels { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps each executor ID to its predecessors (for Fan-In result aggregation).
|
||||
/// </summary>
|
||||
public Dictionary<string, List<string>> Predecessors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps each executor ID to its successors (for Fan-Out result distribution).
|
||||
/// </summary>
|
||||
public Dictionary<string, List<string>> Successors { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps edge connections (sourceId, targetId) to their condition functions.
|
||||
/// The condition function takes the predecessor's result and returns true if the edge should be followed.
|
||||
/// </summary>
|
||||
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> EdgeConditions { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maps executor IDs to their output types (for proper deserialization during condition evaluation).
|
||||
/// </summary>
|
||||
public Dictionary<string, Type?> ExecutorOutputTypes { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this workflow has any parallel execution opportunities.
|
||||
/// </summary>
|
||||
public bool HasParallelism => this.Levels.Any(l => l.Executors.Count > 1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this workflow has any Fan-In points.
|
||||
/// </summary>
|
||||
public bool HasFanIn => this.Levels.Any(l => l.IsFanIn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for analyzing and executing workflows.
|
||||
/// </summary>
|
||||
public static class WorkflowHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Accepts a workflow instance and returns a list of executors with metadata in the order they should be executed.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||
/// <returns>A list of executor information in topological order (execution order).</returns>
|
||||
public static List<WorkflowExecutorInfo> GetExecutorsFromWorkflowInOrder(Workflow workflow)
|
||||
{
|
||||
WorkflowExecutionPlan plan = GetExecutionPlan(workflow);
|
||||
|
||||
// Flatten the levels into a single list for backward compatibility
|
||||
List<WorkflowExecutorInfo> result = [];
|
||||
foreach (WorkflowExecutionLevel level in plan.Levels)
|
||||
{
|
||||
result.AddRange(level.Executors);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes the workflow and returns an execution plan that supports Fan-Out/Fan-In patterns.
|
||||
/// Executors at the same level can be executed in parallel (Fan-Out).
|
||||
/// Fan-In points are identified where multiple executors converge.
|
||||
/// </summary>
|
||||
/// <param name="workflow">The workflow instance to analyze.</param>
|
||||
/// <returns>An execution plan with parallel execution levels.</returns>
|
||||
public static WorkflowExecutionPlan GetExecutionPlan(Workflow workflow)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
Dictionary<string, ExecutorBinding> executors = workflow.ReflectExecutors();
|
||||
Dictionary<string, HashSet<EdgeInfo>> edges = workflow.ReflectEdges();
|
||||
Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> edgeConditions = workflow.GetEdgeConditions();
|
||||
|
||||
WorkflowExecutionPlan plan = new();
|
||||
|
||||
// Build adjacency lists (successors and predecessors)
|
||||
Dictionary<string, List<string>> successors = [];
|
||||
Dictionary<string, List<string>> predecessors = [];
|
||||
Dictionary<string, int> inDegree = [];
|
||||
|
||||
// Initialize all executors and extract their output types
|
||||
foreach (KeyValuePair<string, ExecutorBinding> executor in executors)
|
||||
{
|
||||
successors[executor.Key] = [];
|
||||
predecessors[executor.Key] = [];
|
||||
inDegree[executor.Key] = 0;
|
||||
|
||||
// Extract output type from executor type (e.g., Executor<TInput, TOutput> -> TOutput)
|
||||
plan.ExecutorOutputTypes[executor.Key] = GetExecutorOutputType(executor.Value.ExecutorType);
|
||||
}
|
||||
|
||||
// Build the graph from edges
|
||||
foreach (KeyValuePair<string, HashSet<EdgeInfo>> edgeGroup in edges)
|
||||
{
|
||||
string sourceId = edgeGroup.Key;
|
||||
|
||||
foreach (EdgeInfo edge in edgeGroup.Value)
|
||||
{
|
||||
foreach (string sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
if (executors.ContainsKey(sinkId))
|
||||
{
|
||||
successors[sourceId].Add(sinkId);
|
||||
predecessors[sinkId].Add(sourceId);
|
||||
inDegree[sinkId]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store edge conditions in the plan
|
||||
foreach (KeyValuePair<(string SourceId, string TargetId), Func<object?, bool>?> condition in edgeConditions)
|
||||
{
|
||||
plan.EdgeConditions[condition.Key] = condition.Value;
|
||||
}
|
||||
|
||||
// Store the graph structure in the plan
|
||||
foreach (string executorId in executors.Keys)
|
||||
{
|
||||
plan.Predecessors[executorId] = [.. predecessors[executorId]];
|
||||
plan.Successors[executorId] = [.. successors[executorId]];
|
||||
}
|
||||
|
||||
// Build execution levels using modified Kahn's algorithm
|
||||
// Instead of processing one at a time, we process all nodes with in-degree 0 at once (same level)
|
||||
HashSet<string> processed = [];
|
||||
Dictionary<string, int> currentInDegree = new(inDegree);
|
||||
int levelNumber = 0;
|
||||
|
||||
while (processed.Count < executors.Count)
|
||||
{
|
||||
// Find all executors that can be executed at this level (in-degree == 0 and not yet processed)
|
||||
List<string> currentLevelIds = [];
|
||||
|
||||
foreach (KeyValuePair<string, int> kvp in currentInDegree)
|
||||
{
|
||||
if (kvp.Value == 0 && !processed.Contains(kvp.Key))
|
||||
{
|
||||
currentLevelIds.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// If no executors found but not all processed, there might be a cycle
|
||||
if (currentLevelIds.Count == 0)
|
||||
{
|
||||
// Add remaining unprocessed executors
|
||||
foreach (string executorId in executors.Keys)
|
||||
{
|
||||
if (!processed.Contains(executorId))
|
||||
{
|
||||
currentLevelIds.Add(executorId);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLevelIds.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this level is a Fan-In point (any executor has multiple predecessors)
|
||||
bool isFanIn = currentLevelIds.Any(id => predecessors[id].Count > 1);
|
||||
|
||||
// Convert to WorkflowExecutorInfo
|
||||
List<WorkflowExecutorInfo> levelExecutors = [];
|
||||
foreach (string executorId in currentLevelIds)
|
||||
{
|
||||
processed.Add(executorId);
|
||||
|
||||
if (executors.TryGetValue(executorId, out ExecutorBinding? executorBinding))
|
||||
{
|
||||
bool isAgentic = IsAgentExecutorType(executorBinding.ExecutorType);
|
||||
levelExecutors.Add(new WorkflowExecutorInfo(executorId, isAgentic));
|
||||
}
|
||||
|
||||
// Decrement in-degree of all successors
|
||||
foreach (string successor in successors[executorId])
|
||||
{
|
||||
currentInDegree[successor]--;
|
||||
}
|
||||
}
|
||||
|
||||
plan.Levels.Add(new WorkflowExecutionLevel(levelNumber, levelExecutors, isFanIn));
|
||||
levelNumber++;
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the specified executor type is an agentic executor.
|
||||
/// </summary>
|
||||
/// <param name="executorType">The executor type to check.</param>
|
||||
/// <returns><c>true</c> if the executor is an agentic executor; otherwise, <c>false</c>.</returns>
|
||||
internal static bool IsAgentExecutorType(Type executorType)
|
||||
{
|
||||
// hack for now. In the future, the MAF type could expose something which can help with this.
|
||||
// Check if the type name or assembly indicates it's an agent executor
|
||||
// This includes AgentRunStreamingExecutor, AgentExecutor, ChatClientAgent wrappers, etc.
|
||||
string typeName = executorType.FullName ?? executorType.Name;
|
||||
string assemblyName = executorType.Assembly.GetName().Name ?? string.Empty;
|
||||
|
||||
return typeName.Contains("AIAgentHostExecutor", StringComparison.OrdinalIgnoreCase) &&
|
||||
assemblyName.Contains("Microsoft.Agents.AI", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the output type from an executor type.
|
||||
/// For Executor<TInput, TOutput>, returns TOutput.
|
||||
/// For Executor<TInput>, returns null (void output).
|
||||
/// </summary>
|
||||
/// <param name="executorType">The executor type to analyze.</param>
|
||||
/// <returns>The output type, or null if the executor has no typed output.</returns>
|
||||
private static Type? GetExecutorOutputType(Type executorType)
|
||||
{
|
||||
// Walk up the inheritance chain to find Executor<TInput, TOutput> or Executor<TInput>
|
||||
Type? currentType = executorType;
|
||||
while (currentType is not null)
|
||||
{
|
||||
if (currentType.IsGenericType)
|
||||
{
|
||||
Type genericDefinition = currentType.GetGenericTypeDefinition();
|
||||
Type[] genericArgs = currentType.GetGenericArguments();
|
||||
|
||||
// Check for Executor<TInput, TOutput> (2 type parameters)
|
||||
if (genericArgs.Length == 2 && genericDefinition.Name.StartsWith("Executor", StringComparison.Ordinal))
|
||||
{
|
||||
return genericArgs[1]; // TOutput
|
||||
}
|
||||
|
||||
// Check for Executor<TInput> (1 type parameter) - void return
|
||||
if (genericArgs.Length == 1 && genericDefinition.Name.StartsWith("Executor", StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
currentType = currentType.BaseType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for workflow naming conventions used in durable orchestrations.
|
||||
/// </summary>
|
||||
public static class WorkflowNamingHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The prefix used for durable workflow orchestration function names.
|
||||
/// </summary>
|
||||
public const string OrchestrationFunctionPrefix = "dafx-";
|
||||
|
||||
/// <summary>
|
||||
/// Converts a workflow name to its corresponding orchestration function name.
|
||||
/// </summary>
|
||||
/// <param name="workflowName">The workflow name.</param>
|
||||
/// <returns>The orchestration function name.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow name is null or empty.</exception>
|
||||
public static string ToOrchestrationFunctionName(string workflowName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(workflowName);
|
||||
return $"{OrchestrationFunctionPrefix}{workflowName}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an orchestration function name back to its workflow name.
|
||||
/// </summary>
|
||||
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
|
||||
/// <returns>The workflow name.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the orchestration function name is null, empty, or doesn't have the expected prefix.</exception>
|
||||
public static string ToWorkflowName(string orchestrationFunctionName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(orchestrationFunctionName);
|
||||
|
||||
if (!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Orchestration function name '{orchestrationFunctionName}' does not start with the expected '{OrchestrationFunctionPrefix}' prefix.",
|
||||
nameof(orchestrationFunctionName));
|
||||
}
|
||||
|
||||
string workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
|
||||
|
||||
if (string.IsNullOrEmpty(workflowName))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Orchestration function name '{orchestrationFunctionName}' does not contain a workflow name after the prefix.",
|
||||
nameof(orchestrationFunctionName));
|
||||
}
|
||||
|
||||
return workflowName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to convert an orchestration function name back to its workflow name.
|
||||
/// </summary>
|
||||
/// <param name="orchestrationFunctionName">The orchestration function name.</param>
|
||||
/// <param name="workflowName">When this method returns, contains the workflow name if the conversion succeeded, or null if it failed.</param>
|
||||
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
|
||||
public static bool TryGetWorkflowName(string? orchestrationFunctionName, out string? workflowName)
|
||||
{
|
||||
workflowName = null;
|
||||
|
||||
if (string.IsNullOrEmpty(orchestrationFunctionName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!orchestrationFunctionName.StartsWith(OrchestrationFunctionPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
workflowName = orchestrationFunctionName[OrchestrationFunctionPrefix.Length..];
|
||||
return !string.IsNullOrEmpty(workflowName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The suffix separator used when the workflow builder appends a GUID to executor IDs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For agentic executors, the workflow builder appends a GUID suffix to ensure uniqueness.
|
||||
/// For example: "Physicist_8884e71021334ce49517fa2b17b1695b".
|
||||
/// </remarks>
|
||||
private const char ExecutorIdSuffixSeparator = '_';
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the executor name from an executor ID.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For non-agentic executors, the executor ID is the same as the executor name (e.g., "OrderParser").
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For agentic executors, the workflow builder appends a GUID suffix separated by an underscore
|
||||
/// (e.g., "Physicist_8884e71021334ce49517fa2b17b1695b"). This method extracts just the name portion.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="executorId">The executor ID, which may contain a GUID suffix.</param>
|
||||
/// <returns>The executor name without any GUID suffix.</returns>
|
||||
/// <exception cref="ArgumentException">Thrown when the executor ID is null or empty.</exception>
|
||||
public static string GetExecutorName(string executorId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(executorId);
|
||||
|
||||
int separatorIndex = executorId.IndexOf(ExecutorIdSuffixSeparator);
|
||||
return separatorIndex > 0 ? executorId[..separatorIndex] : executorId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the executor ID contains a GUID suffix.
|
||||
/// </summary>
|
||||
/// <param name="executorId">The executor ID to check.</param>
|
||||
/// <returns><c>true</c> if the executor ID contains a suffix; otherwise, <c>false</c>.</returns>
|
||||
public static bool HasExecutorIdSuffix(string? executorId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(executorId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int separatorIndex = executorId.IndexOf(ExecutorIdSuffixSeparator);
|
||||
return separatorIndex > 0 && separatorIndex < executorId.Length - 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.DurableTask.Entities;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Durable entity that manages workflow state across activities within an orchestration run.
|
||||
/// Each orchestration instance gets its own entity instance (keyed by orchestration instance ID),
|
||||
/// ensuring state isolation between workflow runs. The entity is automatically cleaned up
|
||||
/// when the orchestration completes.
|
||||
/// </summary>
|
||||
public sealed class WorkflowSharedStateEntity : TaskEntity<WorkflowStateData>
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity name used for registration and lookup.
|
||||
/// </summary>
|
||||
public const string EntityName = "workflow-shared-state";
|
||||
|
||||
/// <summary>
|
||||
/// Reads a state value by key and scope.
|
||||
/// </summary>
|
||||
/// <param name="request">The read request containing key and optional scope.</param>
|
||||
/// <returns>The serialized state value, or null if not found.</returns>
|
||||
public string? ReadState(WorkflowStateReadRequest request)
|
||||
{
|
||||
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
|
||||
return this.State.Values.TryGetValue(scopeKey, out string? value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the entire state dictionary.
|
||||
/// </summary>
|
||||
/// <returns>A copy of the current state.</returns>
|
||||
public Dictionary<string, string> ReadAllState()
|
||||
{
|
||||
return new Dictionary<string, string>(this.State.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes or updates a state value by key and scope.
|
||||
/// </summary>
|
||||
/// <param name="request">The write request containing key, scope, and value.</param>
|
||||
public void WriteState(WorkflowStateWriteRequest request)
|
||||
{
|
||||
string scopeKey = GetScopeKey(request.ScopeName, request.Key);
|
||||
|
||||
if (request.Value is null)
|
||||
{
|
||||
this.State.Values.Remove(scopeKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.State.Values[scopeKey] = request.Value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all keys within a specific scope.
|
||||
/// </summary>
|
||||
/// <param name="scopeName">The scope name, or null for the default scope.</param>
|
||||
/// <returns>A collection of keys within the scope.</returns>
|
||||
public HashSet<string> GetStateKeys(string? scopeName)
|
||||
{
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
HashSet<string> keys = [];
|
||||
|
||||
foreach (string scopeKey in this.State.Values.Keys)
|
||||
{
|
||||
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
string key = scopeKey[scopePrefix.Length..];
|
||||
keys.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all state entries within a specific scope.
|
||||
/// </summary>
|
||||
/// <param name="scopeName">The scope name, or null for the default scope.</param>
|
||||
public void ClearScope(string? scopeName)
|
||||
{
|
||||
string scopePrefix = GetScopePrefix(scopeName);
|
||||
List<string> keysToRemove = [];
|
||||
|
||||
foreach (string scopeKey in this.State.Values.Keys)
|
||||
{
|
||||
if (scopeKey.StartsWith(scopePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
keysToRemove.Add(scopeKey);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string key in keysToRemove)
|
||||
{
|
||||
this.State.Values.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the entity, cleaning up all state.
|
||||
/// Called by the orchestration when it completes.
|
||||
/// </summary>
|
||||
public void Delete()
|
||||
{
|
||||
// Setting State to null tells the Durable Task framework to delete the entity.
|
||||
// The entity will be garbage collected after idle timeout.
|
||||
this.State = null!;
|
||||
}
|
||||
|
||||
private static string GetScopeKey(string? scopeName, string key)
|
||||
{
|
||||
return $"{GetScopePrefix(scopeName)}{key}";
|
||||
}
|
||||
|
||||
private static string GetScopePrefix(string? scopeName)
|
||||
{
|
||||
return scopeName is null ? "__default__:" : $"{scopeName}:";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the internal state data for a workflow state entity.
|
||||
/// </summary>
|
||||
public sealed class WorkflowStateData
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the state dictionary mapping scope-prefixed keys to serialized values.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Values { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for reading workflow state.
|
||||
/// </summary>
|
||||
public sealed class WorkflowStateReadRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the state key.
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional scope name.
|
||||
/// </summary>
|
||||
public string? ScopeName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for writing workflow state.
|
||||
/// </summary>
|
||||
public sealed class WorkflowStateWriteRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the state key.
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the optional scope name.
|
||||
/// </summary>
|
||||
public string? ScopeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the serialized value, or null to delete the key.
|
||||
/// </summary>
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
@@ -43,10 +43,14 @@ public static class AIAgentExtensions
|
||||
{
|
||||
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
|
||||
var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false);
|
||||
var options = messageSendParams.Metadata is not { Count: > 0 }
|
||||
? null
|
||||
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
|
||||
|
||||
var response = await hostAgent.RunAsync(
|
||||
messageSendParams.ToChatMessages(),
|
||||
thread: thread,
|
||||
options: options,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false);
|
||||
@@ -56,7 +60,8 @@ public static class AIAgentExtensions
|
||||
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
|
||||
ContextId = contextId,
|
||||
Role = MessageRole.Agent,
|
||||
Parts = parts
|
||||
Parts = parts,
|
||||
Metadata = response.AdditionalProperties?.ToA2AMetadata()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for A2A metadata dictionary.
|
||||
/// </summary>
|
||||
internal static class A2AMetadataExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be replaced by the one from A2A SDK once it is public.
|
||||
/// </remarks>
|
||||
/// <param name="metadata">The metadata dictionary to convert.</param>
|
||||
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
|
||||
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
|
||||
{
|
||||
if (metadata is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var additionalProperties = new AdditionalPropertiesDictionary();
|
||||
foreach (var kvp in metadata)
|
||||
{
|
||||
additionalProperties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return additionalProperties;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for AdditionalPropertiesDictionary.
|
||||
/// </summary>
|
||||
internal static class AdditionalPropertiesDictionaryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method can be replaced by the one from A2A SDK once it is available.
|
||||
/// </remarks>
|
||||
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
|
||||
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
|
||||
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
|
||||
{
|
||||
if (additionalProperties is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadata = new Dictionary<string, JsonElement>();
|
||||
|
||||
foreach (var kvp in additionalProperties)
|
||||
{
|
||||
if (kvp.Value is JsonElement)
|
||||
{
|
||||
metadata[kvp.Key] = (JsonElement)kvp.Value!;
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,44 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
IFunctionInputBindingFeature? functionInputBindingFeature = context.Features.Get<IFunctionInputBindingFeature>() ??
|
||||
throw new InvalidOperationException("Function input binding feature is not available on the current context.");
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
|
||||
{
|
||||
// Bind all inputs to get the input string and DurableTaskClient
|
||||
FunctionInputBindingResult? bindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||
if (bindingResults is not { Values: { } activityBindings })
|
||||
{
|
||||
throw new InvalidOperationException($"Function input binding failed for the invocation {context.InvocationId}");
|
||||
}
|
||||
|
||||
DurableTaskClient? activityDurableTaskClient = null;
|
||||
string? activityInput = null;
|
||||
foreach (object? binding in activityBindings)
|
||||
{
|
||||
if (binding is string stringInput)
|
||||
{
|
||||
activityInput = stringInput;
|
||||
}
|
||||
|
||||
if (binding is DurableTaskClient client)
|
||||
{
|
||||
activityDurableTaskClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
if (activityInput is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Activity input binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
if (activityDurableTaskClient is null)
|
||||
{
|
||||
throw new InvalidOperationException($"DurableTaskClient binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeWorkflowActivityAsync(activityInput, activityDurableTaskClient, context);
|
||||
return;
|
||||
}
|
||||
|
||||
FunctionInputBindingResult? inputBindingResults = await functionInputBindingFeature.BindFunctionInputAsync(context);
|
||||
if (inputBindingResults is not { Values: { } values })
|
||||
{
|
||||
@@ -102,6 +140,32 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint)
|
||||
{
|
||||
if (httpRequestData == null)
|
||||
{
|
||||
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowOrechstrtationHttpTriggerAsync(
|
||||
httpRequestData,
|
||||
durableTaskClient,
|
||||
context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle workflow MCP tool trigger
|
||||
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint)
|
||||
{
|
||||
if (mcpToolInvocationContext is null)
|
||||
{
|
||||
throw new InvalidOperationException($"MCP tool invocation context binding is missing for the invocation {context.InvocationId}.");
|
||||
}
|
||||
|
||||
context.GetInvocationResult().Value = await BuiltInFunctions.RunWorkflowMcpToolAsync(mcpToolInvocationContext, durableTaskClient, context);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unsupported function entry point '{context.FunctionDefinition.EntryPoint}' for invocation {context.InvocationId}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,30 @@ internal static class BuiltInFunctions
|
||||
|
||||
internal static readonly string RunAgentHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunAgentHttpAsync)}";
|
||||
internal static readonly string RunAgentEntityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeAgentAsync)}";
|
||||
internal static readonly string RunWorkflowOrechstrtationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrechstrtationHttpTriggerAsync)}";
|
||||
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
|
||||
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
|
||||
internal static readonly string RunWorkflowMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowMcpToolAsync)}";
|
||||
|
||||
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
|
||||
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
|
||||
#pragma warning restore IL3000
|
||||
|
||||
// Exposed as an activity trigger for workflow executors
|
||||
public static Task<string> InvokeWorkflowActivityAsync(
|
||||
[ActivityTrigger] string input,
|
||||
[DurableClient] DurableTaskClient durableTaskClient,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentNullException.ThrowIfNull(durableTaskClient);
|
||||
ArgumentNullException.ThrowIfNull(functionContext);
|
||||
|
||||
string activityFunctionName = functionContext.FunctionDefinition.Name;
|
||||
|
||||
FunctionsWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<FunctionsWorkflowRunner>();
|
||||
return runner.ExecuteActivityAsync(activityFunctionName, input, durableTaskClient, functionContext);
|
||||
}
|
||||
|
||||
// Exposed as an entity trigger via AgentFunctionsProvider
|
||||
public static Task<string> InvokeAgentAsync(
|
||||
@@ -43,6 +66,24 @@ internal static class BuiltInFunctions
|
||||
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a workflow orchestration in response to an HTTP request.
|
||||
/// </summary>
|
||||
public static async Task<HttpResponseData> RunWorkflowOrechstrtationHttpTriggerAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext context)
|
||||
{
|
||||
var workflowName = context.FunctionDefinition.Name.Replace(HttpPrefix, string.Empty);
|
||||
var orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
var inputMessage = await req.ReadAsStringAsync();
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, inputMessage);
|
||||
|
||||
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
|
||||
await response.WriteStringAsync($"InvokeWorkflowOrechstrtationAsync is invoked for {workflowName}. Orchestration instanceId: {instanceId}");
|
||||
return response;
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseData> RunAgentHttpAsync(
|
||||
[HttpTrigger] HttpRequestData req,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
@@ -178,6 +219,39 @@ internal static class BuiltInFunctions
|
||||
return agentResponse.Text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a workflow via MCP tool trigger.
|
||||
/// </summary>
|
||||
public static async Task<string?> RunWorkflowMcpToolAsync(
|
||||
[McpToolTrigger("BuiltInWorkflowMcpTool")] ToolInvocationContext context,
|
||||
[DurableClient] DurableTaskClient client,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
if (context.Arguments is null)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required arguments.");
|
||||
}
|
||||
|
||||
if (!context.Arguments.TryGetValue("input", out object? inputObj) || inputObj is not string input)
|
||||
{
|
||||
throw new ArgumentException("MCP Tool invocation is missing required 'input' argument of type string.");
|
||||
}
|
||||
|
||||
// Extract workflow name from the MCP tool name (format: mcptool-workflow-{workflowName})
|
||||
string workflowName = context.Name;
|
||||
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
|
||||
string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(orchestrationFunctionName, input);
|
||||
|
||||
// Wait for the orchestration to complete and return the result
|
||||
OrchestrationMetadata? metadata = await client.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: functionContext.CancellationToken);
|
||||
|
||||
return metadata?.ReadOutputAs<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error response with the specified status code and error message.
|
||||
/// </summary>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared configuration logic for durable agents and workflows.
|
||||
/// This class consolidates common service registrations used by both
|
||||
/// <see cref="FunctionsApplicationBuilderExtensions.ConfigureDurableAgents"/> and
|
||||
/// <see cref="DurableOptionsExtensions.ConfigureDurableOptions"/>.
|
||||
/// </summary>
|
||||
internal static class CoreAgentConfigurationExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the core agent services required for durable agents.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder RegisterCoreAgentServices(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>());
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the workflow-specific services required for durable workflows.
|
||||
/// This should only be called when workflows are configured in the application.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder RegisterWorkflowServices(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
// Register FunctionsWorkflowRunner as a singleton
|
||||
builder.Services.TryAddSingleton<FunctionsWorkflowRunner>();
|
||||
|
||||
// Also register it as DurableWorkflowRunner so orchestrations can resolve it by base type
|
||||
builder.Services.TryAddSingleton<DurableWorkflowRunner>(sp => sp.GetRequiredService<FunctionsWorkflowRunner>());
|
||||
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IFunctionMetadataTransformer, DurableWorkflowFunctionMetadataTransformer>());
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the middleware and executor for handling built-in function execution.
|
||||
/// This is shared by both agents and workflows, handling Agent HTTP, MCP tool,
|
||||
/// workflow orchestration, and Entity invocations.
|
||||
/// </summary>
|
||||
/// <param name="builder">The functions application builder.</param>
|
||||
/// <returns>The functions application builder for method chaining.</returns>
|
||||
internal static FunctionsApplicationBuilder ConfigureBuiltInFunctionMiddleware(this FunctionsApplicationBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
|
||||
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
IsBuiltInFunction(context.FunctionDefinition.EntryPoint));
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static bool IsBuiltInFunction(string? entryPoint)
|
||||
{
|
||||
return string.Equals(entryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
|
||||
|| string.Equals(entryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||
{
|
||||
private readonly ILogger<DurableAgentFunctionMetadataTransformer> _logger;
|
||||
private readonly IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> _agents;
|
||||
private readonly DurableAgentsOptions _agentOptions;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IFunctionsAgentOptionsProvider _functionsAgentOptionsProvider;
|
||||
|
||||
@@ -22,12 +22,12 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
#pragma warning restore IL3000
|
||||
|
||||
public DurableAgentFunctionMetadataTransformer(
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents,
|
||||
DurableAgentsOptions agentOptions,
|
||||
ILogger<DurableAgentFunctionMetadataTransformer> logger,
|
||||
IServiceProvider serviceProvider,
|
||||
IFunctionsAgentOptionsProvider functionsAgentOptionsProvider)
|
||||
{
|
||||
this._agents = agents ?? throw new ArgumentNullException(nameof(agents));
|
||||
this._agentOptions = agentOptions ?? throw new ArgumentNullException(nameof(agentOptions));
|
||||
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this._serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
this._functionsAgentOptionsProvider = functionsAgentOptionsProvider ?? throw new ArgumentNullException(nameof(functionsAgentOptionsProvider));
|
||||
@@ -39,7 +39,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
|
||||
{
|
||||
this._logger.LogTransformingFunctionMetadata(original.Count);
|
||||
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agents)
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> kvp in this._agentOptions.GetAgentFactories())
|
||||
{
|
||||
string agentName = kvp.Key;
|
||||
|
||||
|
||||
+33
-5
@@ -113,17 +113,45 @@ public static class DurableAgentsOptionsExtensions
|
||||
Func<IServiceProvider, AIAgent> factory,
|
||||
bool enableHttpTrigger,
|
||||
bool enableMcpToolTrigger)
|
||||
{
|
||||
return AddAIAgentFactory(options, name, factory, enableHttpTrigger, enableMcpToolTrigger, timeToLive: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers an AI agent factory with the specified name, trigger options, and time-to-live configuration.
|
||||
/// </summary>
|
||||
/// <remarks>If both triggers are disabled, the agent will not be accessible via HTTP or MCP tool
|
||||
/// endpoints. This method can be used to register multiple agent factories with different configurations.</remarks>
|
||||
/// <param name="options">The options object to which the AI agent factory will be added. Cannot be null.</param>
|
||||
/// <param name="name">The unique name used to identify the AI agent factory. Cannot be null.</param>
|
||||
/// <param name="factory">A delegate that creates an instance of the AI agent using the provided service provider. Cannot be null.</param>
|
||||
/// <param name="enableHttpTrigger">true to enable the HTTP trigger for the agent; otherwise, false.</param>
|
||||
/// <param name="enableMcpToolTrigger">true to enable the MCP tool trigger for the agent; otherwise, false.</param>
|
||||
/// <param name="timeToLive">Optional time-to-live for this agent's entities.</param>
|
||||
/// <returns>The same DurableAgentsOptions instance, allowing for method chaining.</returns>
|
||||
public static DurableAgentsOptions AddAIAgentFactory(
|
||||
this DurableAgentsOptions options,
|
||||
string name,
|
||||
Func<IServiceProvider, AIAgent> factory,
|
||||
bool enableHttpTrigger,
|
||||
bool enableMcpToolTrigger,
|
||||
TimeSpan? timeToLive)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(name);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
|
||||
FunctionsAgentOptions agentOptions = new();
|
||||
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
|
||||
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
|
||||
// Check if agent options already exist (e.g., from a previous ConfigureDurableAgents call)
|
||||
// If so, preserve the existing options instead of overwriting them
|
||||
if (!s_agentOptions.ContainsKey(name))
|
||||
{
|
||||
FunctionsAgentOptions agentOptions = new();
|
||||
agentOptions.HttpTrigger.IsEnabled = enableHttpTrigger;
|
||||
agentOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger;
|
||||
s_agentOptions[name] = agentOptions;
|
||||
}
|
||||
|
||||
options.AddAIAgentFactory(name, factory);
|
||||
s_agentOptions[name] = agentOptions;
|
||||
options.AddAIAgentFactory(name, factory, timeToLive);
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring durable options (agents and workflows).
|
||||
/// </summary>
|
||||
public static class DurableOptionsExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures durable agents and workflows in a unified way.
|
||||
/// </summary>
|
||||
/// <param name="builder">The Functions application builder.</param>
|
||||
/// <param name="configure">A delegate to configure the durable options.</param>
|
||||
/// <returns>The Functions application builder for method chaining.</returns>
|
||||
/// <remarks>
|
||||
/// This method provides a unified configuration point for both durable agents and workflows.
|
||||
/// It automatically generates HTTP API endpoints for agents and workflows, and configures
|
||||
/// the necessary middleware and services for durable execution.
|
||||
/// </remarks>
|
||||
public static FunctionsApplicationBuilder ConfigureDurableOptions(
|
||||
this FunctionsApplicationBuilder builder,
|
||||
Action<DurableOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
DurableOptions options = new();
|
||||
configure(options);
|
||||
|
||||
RegisterServices(builder, options);
|
||||
ConfigureAgents(builder, options);
|
||||
builder.ConfigureBuiltInFunctionMiddleware();
|
||||
|
||||
if (options.Workflows.Workflows.Count > 0)
|
||||
{
|
||||
builder.RegisterWorkflowServices();
|
||||
ConfigureWorkflowOrchestrations(builder, options.Workflows);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static void RegisterServices(FunctionsApplicationBuilder builder, DurableOptions options)
|
||||
{
|
||||
builder.Services.TryAddSingleton(options);
|
||||
builder.Services.TryAddSingleton(options.Agents);
|
||||
|
||||
builder.RegisterCoreAgentServices();
|
||||
}
|
||||
|
||||
private static void ConfigureAgents(FunctionsApplicationBuilder builder, DurableOptions options)
|
||||
{
|
||||
// Only configure agents if there are any agent factories registered in DurableOptions
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories = options.Agents.GetAgentFactories();
|
||||
if (agentFactories.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
builder.Services.ConfigureDurableAgents(agentOpts =>
|
||||
{
|
||||
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> agentFactory in agentFactories)
|
||||
{
|
||||
bool isWorkflowOnly = options.Agents.IsWorkflowOnly(agentFactory.Key);
|
||||
|
||||
agentOpts.AddAIAgentFactory(
|
||||
agentFactory.Key,
|
||||
agentFactory.Value,
|
||||
enableHttpTrigger: !isWorkflowOnly,
|
||||
enableMcpToolTrigger: false,
|
||||
timeToLive: options.Agents.GetTimeToLive(agentFactory.Key));
|
||||
}
|
||||
|
||||
agentOpts.DefaultTimeToLive = options.Agents.DefaultTimeToLive;
|
||||
agentOpts.MinimumTimeToLiveSignalDelay = options.Agents.MinimumTimeToLiveSignalDelay;
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureWorkflowOrchestrations(FunctionsApplicationBuilder builder, DurableWorkflowOptions workflows)
|
||||
{
|
||||
builder.ConfigureDurableWorker().AddTasks(tasks =>
|
||||
{
|
||||
// Register the workflow state entity for shared state management within workflows.
|
||||
tasks.AddEntity<WorkflowSharedStateEntity>(WorkflowSharedStateEntity.EntityName);
|
||||
|
||||
foreach (string workflowName in workflows.Workflows.Select(kp => kp.Key))
|
||||
{
|
||||
string orchestrationFunctionName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflowName);
|
||||
|
||||
tasks.AddOrchestratorFunc<string, string>(
|
||||
orchestrationFunctionName,
|
||||
async (orchestrationContext, request) =>
|
||||
{
|
||||
FunctionContext functionContext = orchestrationContext.GetFunctionContext()
|
||||
?? throw new InvalidOperationException("FunctionContext is not available in the orchestration context.");
|
||||
|
||||
DurableWorkflowRunner runner = functionContext.InstanceServices.GetRequiredService<DurableWorkflowRunner>();
|
||||
ILogger logger = orchestrationContext.CreateReplaySafeLogger(orchestrationFunctionName);
|
||||
|
||||
return await runner.RunWorkflowOrchestrationAsync(orchestrationContext, request, logger).ConfigureAwait(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
internal sealed class DurableWorkflowFunctionMetadataTransformer : IFunctionMetadataTransformer
|
||||
{
|
||||
private readonly ILogger<DurableWorkflowFunctionMetadataTransformer> _logger;
|
||||
private readonly DurableWorkflowOptions _options;
|
||||
|
||||
public DurableWorkflowFunctionMetadataTransformer(ILogger<DurableWorkflowFunctionMetadataTransformer> logger, DurableOptions durableOptions)
|
||||
{
|
||||
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
ArgumentNullException.ThrowIfNull(durableOptions);
|
||||
this._options = durableOptions.Workflows;
|
||||
}
|
||||
|
||||
public string Name => nameof(DurableWorkflowFunctionMetadataTransformer);
|
||||
|
||||
public void Transform(IList<IFunctionMetadata> original)
|
||||
{
|
||||
this._logger.LogTransformStart(original.Count);
|
||||
|
||||
// Track registered function names to avoid duplicates when the same executor is used in multiple workflows
|
||||
HashSet<string> registeredFunctionNames = new();
|
||||
|
||||
foreach (var workflow in this._options.Workflows)
|
||||
{
|
||||
this._logger.LogAddingWorkflowFunction(workflow.Key);
|
||||
|
||||
// Currently due to how durable executor is registered, we are not able to bind TaskOrechestrationContext parameter properly
|
||||
// because the InputBinding for TOC happens inside the DurableExecutor (rathen than in an input converter).
|
||||
// So for now, we are going to use single orchestration function for all workflows.
|
||||
//original.Add(CreateOrchestrationTrigger(workflow.Key));
|
||||
|
||||
// We also want to create an HTTP trigger for this orchestration so users can start it via HTTP.
|
||||
this._logger.LogAddingHttpTrigger(workflow.Key);
|
||||
original.Add(CreateHttpTrigger(workflow.Key, $"workflows/{workflow.Key}/run"));
|
||||
|
||||
// Check if MCP tool trigger is enabled for this workflow
|
||||
if (DurableWorkflowOptionsExtensions.TryGetWorkflowOptions(workflow.Key, out FunctionsWorkflowOptions? workflowOptions) &&
|
||||
workflowOptions?.McpToolTrigger.IsEnabled == true)
|
||||
{
|
||||
this._logger.LogAddingMcpToolTrigger(workflow.Key);
|
||||
original.Add(CreateMcpToolTrigger(workflow.Key, workflow.Value.Description));
|
||||
}
|
||||
|
||||
// Create activity/entity functions for each executor in the workflow based on their type
|
||||
// Extract executor IDs from edges and start executor
|
||||
HashSet<string> executorIds = new() { workflow.Value.StartExecutorId };
|
||||
|
||||
var reflectedEdges = workflow.Value.ReflectEdges();
|
||||
foreach (var (sourceId, edgeSet) in reflectedEdges)
|
||||
{
|
||||
executorIds.Add(sourceId);
|
||||
foreach (var edge in edgeSet)
|
||||
{
|
||||
foreach (var sinkId in edge.Connection.SinkIds)
|
||||
{
|
||||
executorIds.Add(sinkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<string, ExecutorBinding> executorBindings = workflow.Value.ReflectExecutors();
|
||||
|
||||
foreach (string executorId in executorIds)
|
||||
{
|
||||
if (executorBindings.TryGetValue(executorId, out ExecutorBinding? executorBinding))
|
||||
{
|
||||
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
|
||||
string functionName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
|
||||
|
||||
// Skip if this function has already been registered by another workflow
|
||||
if (!registeredFunctionNames.Add(functionName))
|
||||
{
|
||||
this._logger.LogSkippingDuplicateFunction(functionName, workflow.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the executor type is an agent-related type
|
||||
if (executorBinding is AIAgentBinding)
|
||||
{
|
||||
this._logger.LogAddingAgentEntityFunction(executorId, executorBinding.ExecutorType.FullName ?? executorBinding.ExecutorType.Name, workflow.Key);
|
||||
//original.Add(CreateAgentTrigger(functionName));
|
||||
}
|
||||
else
|
||||
{
|
||||
this._logger.LogAddingActivityFunction(executorId, executorBinding.ExecutorType.FullName ?? executorBinding.ExecutorType.Name, workflow.Key);
|
||||
original.Add(CreateActivityTrigger(functionName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._logger.LogTransformFinished(original.Count);
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateHttpTrigger(string name, string route)
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
Name = $"{BuiltInFunctions.HttpPrefix}{name}",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
|
||||
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
|
||||
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunWorkflowOrechstrtationHttpFunctionEntryPoint,
|
||||
ScriptFile = BuiltInFunctions.ScriptFile
|
||||
};
|
||||
}
|
||||
|
||||
//private static DefaultFunctionMetadata CreateOrchestrationTrigger(string name)
|
||||
//{
|
||||
// return new DefaultFunctionMetadata()
|
||||
// {
|
||||
// Name = AgentSessionId.ToEntityName(name),
|
||||
// Language = "dotnet-isolated",
|
||||
// RawBindings =
|
||||
// [
|
||||
// // """{"name":"context","type":"orchestrationTrigger","direction":"In"}""",
|
||||
// """{"name":"taskOrchestrationContext","type":"orchestrationTrigger","direction":"In"}""",
|
||||
|
||||
// ],
|
||||
// EntryPoint = BuiltInFunctions.RunWorkflowOrechstrtationFunctionEntryPoint,
|
||||
// ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
// };
|
||||
//}
|
||||
|
||||
private static DefaultFunctionMetadata CreateActivityTrigger(string functionName)
|
||||
{
|
||||
return new DefaultFunctionMetadata()
|
||||
{
|
||||
Name = functionName,
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
"""{"name":"input","type":"activityTrigger","direction":"In","dataType":"String"}""",
|
||||
"""{"name":"durableTaskClient","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint,
|
||||
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultFunctionMetadata CreateMcpToolTrigger(string workflowName, string? description)
|
||||
{
|
||||
return new DefaultFunctionMetadata
|
||||
{
|
||||
Name = $"{BuiltInFunctions.McpToolPrefix}{workflowName}",
|
||||
Language = "dotnet-isolated",
|
||||
RawBindings =
|
||||
[
|
||||
$$"""{"name":"context","type":"mcpToolTrigger","direction":"In","toolName":"{{workflowName}}","description":"{{description ?? $"Run the {workflowName} workflow"}}","toolProperties":"[{\"propertyName\":\"input\",\"propertyType\":\"string\",\"description\":\"The input to the workflow.\",\"isRequired\":true,\"isArray\":false}]"}""",
|
||||
"""{"name":"input","type":"mcpToolProperty","direction":"In","propertyName":"input","description":"The input to the workflow","isRequired":true,"dataType":"String","propertyType":"string"}""",
|
||||
"""{"name":"client","type":"durableClient","direction":"In"}"""
|
||||
],
|
||||
EntryPoint = BuiltInFunctions.RunWorkflowMcpToolFunctionEntryPoint,
|
||||
ScriptFile = BuiltInFunctions.ScriptFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Logging messages for <see cref="DurableWorkflowFunctionMetadataTransformer"/>.
|
||||
/// </summary>
|
||||
[ExcludeFromCodeCoverage]
|
||||
internal static partial class DurableWorkflowFunctionMetadataTransformerLogs
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 200,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Transforming function metadata to add durable workflow functions. Initial function count: {FunctionCount}")]
|
||||
public static partial void LogTransformStart(this ILogger logger, int functionCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 201,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Adding durable workflow function for workflow: {WorkflowName}")]
|
||||
public static partial void LogAddingWorkflowFunction(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 202,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Adding HTTP trigger function for workflow: {WorkflowName}")]
|
||||
public static partial void LogAddingHttpTrigger(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 203,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Adding activity function for executor: {ExecutorId} (Type: {ExecutorType}) in workflow: {WorkflowName}")]
|
||||
public static partial void LogAddingActivityFunction(this ILogger logger, string executorId, string executorType, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 204,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Adding agent entity function for executor: {ExecutorId} (Type: {ExecutorType}) in workflow: {WorkflowName}")]
|
||||
public static partial void LogAddingAgentEntityFunction(this ILogger logger, string executorId, string executorType, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 205,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Adding MCP tool trigger function for workflow: {WorkflowName}")]
|
||||
public static partial void LogAddingMcpToolTrigger(this ILogger logger, string workflowName);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 206,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Transform finished. Updated function count: {FunctionCount}")]
|
||||
public static partial void LogTransformFinished(this ILogger logger, int functionCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 207,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Skipping duplicate function registration: {FunctionName} (already registered by another workflow) in workflow: {WorkflowName}")]
|
||||
public static partial void LogSkippingDuplicateFunction(this ILogger logger, string functionName, string workflowName);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for registering and configuring workflows in the context of the Azure Functions hosting environment.
|
||||
/// </summary>
|
||||
public static class DurableWorkflowOptionsExtensions
|
||||
{
|
||||
// Registry of workflow options.
|
||||
private static readonly Dictionary<string, FunctionsWorkflowOptions> s_workflowOptions = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow to the specified <see cref="DurableWorkflowOptions"/> instance and optionally configures
|
||||
/// workflow-specific options.
|
||||
/// </summary>
|
||||
/// <param name="options">The <see cref="DurableWorkflowOptions"/> instance to which the workflow will be added.</param>
|
||||
/// <param name="workflow">The workflow to add. The workflow's Name property must not be null or empty.</param>
|
||||
/// <param name="configure">An optional delegate to configure workflow-specific options. If null, default options are used.</param>
|
||||
/// <returns>The updated <see cref="DurableWorkflowOptions"/> instance containing the added workflow.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="workflow"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public static DurableWorkflowOptions AddWorkflow(
|
||||
this DurableWorkflowOptions options,
|
||||
Workflow workflow,
|
||||
Action<FunctionsWorkflowOptions>? configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(workflow);
|
||||
|
||||
if (string.IsNullOrEmpty(workflow.Name))
|
||||
{
|
||||
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
|
||||
}
|
||||
|
||||
// Initialize with default behavior (MCP trigger disabled)
|
||||
FunctionsWorkflowOptions workflowOptions = new();
|
||||
configure?.Invoke(workflowOptions);
|
||||
|
||||
options.AddWorkflow(workflow);
|
||||
s_workflowOptions[workflow.Name] = workflowOptions;
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a workflow to the specified <see cref="DurableWorkflowOptions"/> instance and configures
|
||||
/// trigger support for MCP tool invocations.
|
||||
/// </summary>
|
||||
/// <param name="options">The <see cref="DurableWorkflowOptions"/> instance to which the workflow will be added.</param>
|
||||
/// <param name="workflow">The workflow to add. The workflow's Name property must not be null or empty.</param>
|
||||
/// <param name="enableMcpToolTrigger">true to enable an MCP tool trigger for the workflow; otherwise, false.</param>
|
||||
/// <returns>The updated <see cref="DurableWorkflowOptions"/> instance with the specified workflow and trigger configuration applied.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="options"/> or <paramref name="workflow"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
|
||||
public static DurableWorkflowOptions AddWorkflow(
|
||||
this DurableWorkflowOptions options,
|
||||
Workflow workflow,
|
||||
bool enableMcpToolTrigger)
|
||||
{
|
||||
return AddWorkflow(options, workflow, workflowOptions => workflowOptions.McpToolTrigger.IsEnabled = enableMcpToolTrigger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get the <see cref="FunctionsWorkflowOptions"/> for a workflow by name.
|
||||
/// </summary>
|
||||
/// <param name="workflowName">The name of the workflow.</param>
|
||||
/// <param name="workflowOptions">When this method returns, contains the workflow options if found; otherwise, null.</param>
|
||||
/// <returns><c>true</c> if the workflow options were found; otherwise, <c>false</c>.</returns>
|
||||
internal static bool TryGetWorkflowOptions(string workflowName, out FunctionsWorkflowOptions? workflowOptions)
|
||||
{
|
||||
return s_workflowOptions.TryGetValue(workflowName, out workflowOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the workflow options used for dependency injection (read-only copy).
|
||||
/// </summary>
|
||||
internal static IReadOnlyDictionary<string, FunctionsWorkflowOptions> GetWorkflowOptionsSnapshot()
|
||||
{
|
||||
return new Dictionary<string, FunctionsWorkflowOptions>(s_workflowOptions, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Azure Functions-specific workflow runner that extends the base <see cref="DurableWorkflowRunner"/>
|
||||
/// with Azure Functions activity execution support.
|
||||
/// </summary>
|
||||
internal sealed class FunctionsWorkflowRunner : DurableWorkflowRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FunctionsWorkflowRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
|
||||
public FunctionsWorkflowRunner(ILogger<FunctionsWorkflowRunner> logger, DurableOptions durableOptions)
|
||||
: base(logger, durableOptions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an activity function for a workflow executor.
|
||||
/// </summary>
|
||||
/// <param name="activityFunctionName">The name of the activity function to execute.</param>
|
||||
/// <param name="input">The serialized executor input.</param>
|
||||
/// <param name="durableTaskClient">The durable task client for entity operations.</param>
|
||||
/// <param name="functionContext">The function context containing binding data with the orchestration instance ID.</param>
|
||||
/// <returns>The serialized executor output.</returns>
|
||||
internal async Task<string> ExecuteActivityAsync(
|
||||
string activityFunctionName,
|
||||
string input,
|
||||
DurableTaskClient durableTaskClient,
|
||||
FunctionContext functionContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(activityFunctionName);
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
ArgumentNullException.ThrowIfNull(durableTaskClient);
|
||||
ArgumentNullException.ThrowIfNull(functionContext);
|
||||
|
||||
string executorName = ParseExecutorName(activityFunctionName);
|
||||
|
||||
if (!this.Options.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration) || registration is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Executor '{executorName}' not found in the executor registry.");
|
||||
}
|
||||
|
||||
this.Logger.LogExecutingActivity(registration.ExecutorId, executorName);
|
||||
|
||||
Executor executor = await registration.CreateExecutorInstanceAsync("activity-run", CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Type inputType = executor.InputTypes.FirstOrDefault() ?? typeof(string);
|
||||
object typedInput = DeserializeInput(input, inputType);
|
||||
|
||||
// Get the orchestration instance ID from the function context binding data
|
||||
string instanceId = GetInstanceIdFromContext(functionContext)
|
||||
?? throw new InvalidOperationException(
|
||||
"Could not retrieve orchestration instance ID from FunctionContext. " +
|
||||
"Ensure the activity is being called from within a durable orchestration.");
|
||||
|
||||
// Create context with durable entity-backed state
|
||||
IWorkflowContext context = CreateExecutorContext(instanceId, durableTaskClient);
|
||||
|
||||
object? result = await executor.ExecuteAsync(
|
||||
typedInput,
|
||||
new TypeId(inputType),
|
||||
context,
|
||||
CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
return SerializeResult(result);
|
||||
}
|
||||
|
||||
private static string? GetInstanceIdFromContext(FunctionContext functionContext)
|
||||
{
|
||||
if (functionContext.BindingContext.BindingData.TryGetValue("instanceId", out object? instanceIdObj) &&
|
||||
instanceIdObj is string instanceId)
|
||||
{
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL2026:RequiresUnreferencedCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = "DurableExecutorContext state serialization is done at runtime with user-known types.")]
|
||||
private static DurableExecutorContext CreateExecutorContext(
|
||||
string instanceId,
|
||||
DurableTaskClient client)
|
||||
{
|
||||
return new DurableExecutorContext(instanceId, client);
|
||||
}
|
||||
}
|
||||
+4
-14
@@ -2,10 +2,6 @@
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Builder;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
@@ -24,22 +20,16 @@ public static class FunctionsApplicationBuilderExtensions
|
||||
this FunctionsApplicationBuilder builder,
|
||||
Action<DurableAgentsOptions> configure)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
// The main agent services registration is done in Microsoft.DurableTask.Agents.
|
||||
builder.Services.ConfigureDurableAgents(configure);
|
||||
|
||||
builder.Services.TryAddSingleton<IFunctionsAgentOptionsProvider>(_ =>
|
||||
new DefaultFunctionsAgentOptionsProvider(DurableAgentsOptionsExtensions.GetAgentOptionsSnapshot()));
|
||||
builder.RegisterCoreAgentServices();
|
||||
|
||||
builder.Services.AddSingleton<IFunctionMetadataTransformer, DurableAgentFunctionMetadataTransformer>();
|
||||
|
||||
// Handling of built-in function execution for Agent HTTP, MCP tool, or Entity invocations.
|
||||
builder.UseWhen<BuiltInFunctionExecutionMiddleware>(static context =>
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentHttpFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentMcpToolFunctionEntryPoint, StringComparison.Ordinal) ||
|
||||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal));
|
||||
builder.Services.AddSingleton<BuiltInFunctionExecutor>();
|
||||
// Configure middleware for built-in function execution.
|
||||
builder.ConfigureBuiltInFunctionMiddleware();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides configuration options for enabling and customizing function triggers for a workflow.
|
||||
/// </summary>
|
||||
public sealed class FunctionsWorkflowOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the options used to configure the MCP tool trigger behavior.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By default, MCP tool trigger is disabled for workflows.
|
||||
/// </remarks>
|
||||
public McpToolTriggerOptions McpToolTrigger { get; set; } = new(false);
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- CA2007: This rule should generally be suppressed in Durable Task libraries. Also, this is not library code. -->
|
||||
<NoWarn>$(NoWarn);CA2007</NoWarn>
|
||||
<NoWarn>$(NoWarn);CA2007;AD0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
@@ -50,7 +50,7 @@ TokenCredential browserCredential = new InteractiveBrowserCredential(
|
||||
IChatClient client = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.WithPurview(browserCredential, new PurviewSettings("My Sample App"))
|
||||
@@ -198,7 +198,7 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal
|
||||
IChatClient client = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetOpenAIResponseClient(deploymentName)
|
||||
.GetResponsesClient(deploymentName)
|
||||
.AsIChatClient()
|
||||
.AsBuilder()
|
||||
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
|
||||
|
||||
@@ -35,6 +35,39 @@ public class Workflow
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the condition functions for direct edges, keyed by (sourceId, targetId) tuple.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary mapping edge connections to their condition functions (null if no condition).</returns>
|
||||
/// <remarks>This method creates a new dictionary each time it is called to ensure thread safety.</remarks>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Method creates a new collection on each call.")]
|
||||
public Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> GetEdgeConditions()
|
||||
{
|
||||
Dictionary<(string SourceId, string TargetId), Func<object?, bool>?> conditions = [];
|
||||
|
||||
foreach (KeyValuePair<string, HashSet<Edge>> edgeGroup in this.Edges)
|
||||
{
|
||||
foreach (Edge edge in edgeGroup.Value)
|
||||
{
|
||||
if (edge.DirectEdgeData is DirectEdgeData directEdge)
|
||||
{
|
||||
conditions[(directEdge.SourceId, directEdge.SinkId)] = directEdge.Condition;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all executor bindings in the workflow, keyed by their ID.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary mapping executor IDs to their <see cref="ExecutorBinding"/>.</returns>
|
||||
public Dictionary<string, ExecutorBinding> ReflectExecutors()
|
||||
{
|
||||
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
|
||||
}
|
||||
|
||||
internal Dictionary<string, RequestPort> Ports { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -10,4 +10,10 @@ namespace Microsoft.Agents.AI.Workflows;
|
||||
/// <param name="e">
|
||||
/// Optionally, the <see cref="Exception"/> representing the error.
|
||||
/// </param>
|
||||
public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
|
||||
public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the exception that caused the current operation to fail, if one occurred.
|
||||
/// </summary>
|
||||
public Exception? Exception => this.Data as Exception;
|
||||
}
|
||||
|
||||
@@ -18,11 +18,12 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
private readonly string? _id;
|
||||
private readonly CheckpointManager? _checkpointManager;
|
||||
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
|
||||
private readonly bool _includeExceptionDetails;
|
||||
private readonly Task<ProtocolDescriptor> _describeTask;
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _assignedRunIds = [];
|
||||
|
||||
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null)
|
||||
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false)
|
||||
{
|
||||
this._workflow = Throw.IfNull(workflow);
|
||||
|
||||
@@ -30,6 +31,7 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
? InProcessExecution.Concurrent
|
||||
: InProcessExecution.OffThread);
|
||||
this._checkpointManager = checkpointManager;
|
||||
this._includeExceptionDetails = includeExceptionDetails;
|
||||
|
||||
this._id = id;
|
||||
this.Name = name;
|
||||
@@ -61,10 +63,10 @@ internal sealed class WorkflowHostAgent : AIAgent
|
||||
protocol.ThrowIfNotChatProtocol();
|
||||
}
|
||||
|
||||
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager);
|
||||
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails);
|
||||
|
||||
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions);
|
||||
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions);
|
||||
|
||||
private ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,8 @@ public static class WorkflowHostingExtensions
|
||||
/// <param name="executionEnvironment">Specify the execution environment to use when running the workflows. See
|
||||
/// <see cref="InProcessExecution.OffThread"/>, <see cref="InProcessExecution.Concurrent"/> and
|
||||
/// <see cref="InProcessExecution.Lockstep"/> for the in-process environments.</param>
|
||||
/// <param name="includeExceptionDetails">If <see langword="true"/>, will include <see cref="System.Exception.Message"/>
|
||||
/// in the <see cref="ErrorContent"/> representing the workflow error.</param>
|
||||
/// <returns></returns>
|
||||
public static AIAgent AsAgent(
|
||||
this Workflow workflow,
|
||||
@@ -28,9 +30,10 @@ public static class WorkflowHostingExtensions
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
CheckpointManager? checkpointManager = null,
|
||||
IWorkflowExecutionEnvironment? executionEnvironment = null)
|
||||
IWorkflowExecutionEnvironment? executionEnvironment = null,
|
||||
bool includeExceptionDetails = false)
|
||||
{
|
||||
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment);
|
||||
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment, includeExceptionDetails);
|
||||
}
|
||||
|
||||
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -17,14 +18,16 @@ internal sealed class WorkflowThread : AgentThread
|
||||
{
|
||||
private readonly Workflow _workflow;
|
||||
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
|
||||
private readonly bool _includeExceptionDetails;
|
||||
|
||||
private readonly CheckpointManager _checkpointManager;
|
||||
private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager;
|
||||
|
||||
public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null)
|
||||
public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false)
|
||||
{
|
||||
this._workflow = Throw.IfNull(workflow);
|
||||
this._executionEnvironment = Throw.IfNull(executionEnvironment);
|
||||
this._includeExceptionDetails = includeExceptionDetails;
|
||||
|
||||
// If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one.
|
||||
// TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded
|
||||
@@ -35,7 +38,7 @@ internal sealed class WorkflowThread : AgentThread
|
||||
this.MessageStore = new WorkflowMessageStore();
|
||||
}
|
||||
|
||||
public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
this._workflow = Throw.IfNull(workflow);
|
||||
this._executionEnvironment = Throw.IfNull(executionEnvironment);
|
||||
@@ -80,7 +83,7 @@ internal sealed class WorkflowThread : AgentThread
|
||||
return marshaller.Marshal(info);
|
||||
}
|
||||
|
||||
public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] parts)
|
||||
public AgentRunResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts)
|
||||
{
|
||||
Throw.IfNullOrEmpty(parts);
|
||||
|
||||
@@ -89,7 +92,8 @@ internal sealed class WorkflowThread : AgentThread
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
Role = ChatRole.Assistant,
|
||||
ResponseId = responseId
|
||||
ResponseId = responseId,
|
||||
RawRepresentation = raw
|
||||
};
|
||||
|
||||
this.MessageStore.AddMessages(update.ToChatMessage());
|
||||
@@ -153,10 +157,29 @@ internal sealed class WorkflowThread : AgentThread
|
||||
|
||||
case RequestInfoEvent requestInfo:
|
||||
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
|
||||
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent);
|
||||
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
|
||||
yield return update;
|
||||
break;
|
||||
|
||||
case WorkflowErrorEvent workflowError:
|
||||
Exception? exception = workflowError.Exception;
|
||||
if (exception is TargetInvocationException tie && tie.InnerException != null)
|
||||
{
|
||||
exception = tie.InnerException;
|
||||
}
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
string message = this._includeExceptionDetails
|
||||
? exception.Message
|
||||
: "An error occurred while executing the workflow.";
|
||||
|
||||
ErrorContent errorContent = new(message);
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
goto default;
|
||||
|
||||
@@ -162,7 +162,10 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
static AgentRunResponse CreateResponse(ChatResponse chatResponse)
|
||||
{
|
||||
return new AgentRunResponse(chatResponse);
|
||||
return new AgentRunResponse(chatResponse)
|
||||
{
|
||||
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
|
||||
};
|
||||
}
|
||||
|
||||
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, thread, options, cancellationToken);
|
||||
@@ -201,11 +204,14 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
|
||||
(ChatClientAgentThread safeThread,
|
||||
ChatOptions? chatOptions,
|
||||
List<ChatMessage> inputMessagesForChatClient,
|
||||
IList<ChatMessage>? aiContextProviderMessages,
|
||||
IList<ChatMessage>? chatMessageStoreMessages,
|
||||
ChatClientAgentContinuationToken? continuationToken) =
|
||||
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread);
|
||||
|
||||
var chatClient = this.ChatClient;
|
||||
|
||||
chatClient = ApplyRunOptionsTransformations(options, chatClient);
|
||||
@@ -214,7 +220,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
|
||||
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
|
||||
|
||||
List<ChatResponseUpdate> responseUpdates = [];
|
||||
List<ChatResponseUpdate> responseUpdates = GetResponseUpdates(continuationToken);
|
||||
|
||||
IAsyncEnumerator<ChatResponseUpdate> responseUpdatesEnumerator;
|
||||
|
||||
@@ -225,8 +231,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -240,8 +246,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -253,7 +259,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
update.AuthorName ??= this.Name;
|
||||
|
||||
responseUpdates.Add(update);
|
||||
yield return new(update) { AgentId = this.Id };
|
||||
|
||||
yield return new(update)
|
||||
{
|
||||
AgentId = this.Id,
|
||||
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
@@ -262,8 +273,8 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -275,10 +286,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 NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Notify the AIContextProvider of all new messages.
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
await NotifyAIContextProviderOfSuccessAsync(safeThread, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -382,7 +393,12 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
{
|
||||
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
|
||||
|
||||
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
|
||||
(ChatClientAgentThread safeThread,
|
||||
ChatOptions? chatOptions,
|
||||
List<ChatMessage> inputMessagesForChatClient,
|
||||
IList<ChatMessage>? aiContextProviderMessages,
|
||||
IList<ChatMessage>? chatMessageStoreMessages,
|
||||
ChatClientAgentContinuationToken? _) =
|
||||
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var chatClient = this.ChatClient;
|
||||
@@ -474,20 +490,20 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <param name="runOptions">Optional run options that may include specific chat configuration settings.</param>
|
||||
/// <returns>A <see cref="ChatOptions"/> object representing the merged chat configuration, or <see langword="null"/> if
|
||||
/// neither the run options nor the agent's chat options are available.</returns>
|
||||
private ChatOptions? CreateConfiguredChatOptions(AgentRunOptions? runOptions)
|
||||
private (ChatOptions?, ChatClientAgentContinuationToken?) CreateConfiguredChatOptions(AgentRunOptions? runOptions)
|
||||
{
|
||||
ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone();
|
||||
|
||||
// If no agent chat options were provided, return the request chat options as is.
|
||||
if (this._agentOptions?.ChatOptions is null)
|
||||
{
|
||||
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
|
||||
return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
|
||||
}
|
||||
|
||||
// If no request chat options were provided, use the agent's chat options clone.
|
||||
if (requestChatOptions is null)
|
||||
{
|
||||
return ApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
|
||||
return GetContinuationTokenAndApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
|
||||
}
|
||||
|
||||
// If both are present, we need to merge them.
|
||||
@@ -583,19 +599,26 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
|
||||
return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
|
||||
|
||||
static ChatOptions? ApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
|
||||
static (ChatOptions?, ChatClientAgentContinuationToken?) GetContinuationTokenAndApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
|
||||
{
|
||||
// If any of the background response properties are set in the run options, we should apply both to the chat options.
|
||||
if (agentRunOptions?.AllowBackgroundResponses is not null || agentRunOptions?.ContinuationToken is not null)
|
||||
if (agentRunOptions?.AllowBackgroundResponses is not null)
|
||||
{
|
||||
chatOptions ??= new ChatOptions();
|
||||
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
|
||||
chatOptions.ContinuationToken = agentRunOptions.ContinuationToken;
|
||||
}
|
||||
|
||||
return chatOptions;
|
||||
ChatClientAgentContinuationToken? agentContinuationToken = null;
|
||||
|
||||
if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken)
|
||||
{
|
||||
agentContinuationToken = ChatClientAgentContinuationToken.FromToken(continuationToken);
|
||||
chatOptions ??= new ChatOptions();
|
||||
chatOptions.ContinuationToken = agentContinuationToken!.InnerToken;
|
||||
}
|
||||
|
||||
return (chatOptions, agentContinuationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,21 +629,22 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
/// <param name="inputMessages">The input messages to use.</param>
|
||||
/// <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>
|
||||
/// <returns>A tuple containing the thread, chat options, messages and continuation token.</returns>
|
||||
private async Task
|
||||
<(
|
||||
ChatClientAgentThread AgentThread,
|
||||
ChatOptions? ChatOptions,
|
||||
List<ChatMessage> InputMessagesForChatClient,
|
||||
IList<ChatMessage>? AIContextProviderMessages,
|
||||
IList<ChatMessage>? ChatMessageStoreMessages
|
||||
IList<ChatMessage>? ChatMessageStoreMessages,
|
||||
ChatClientAgentContinuationToken? ContinuationToken
|
||||
)> PrepareThreadAndMessagesAsync(
|
||||
AgentThread? thread,
|
||||
IEnumerable<ChatMessage> inputMessages,
|
||||
AgentRunOptions? runOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
|
||||
(ChatOptions? chatOptions, ChatClientAgentContinuationToken? continuationToken) = this.CreateConfiguredChatOptions(runOptions);
|
||||
|
||||
// Supplying a thread for background responses is required to prevent inconsistent experience
|
||||
// for callers if they forget to provide the thread for initial or follow-up runs.
|
||||
@@ -641,11 +665,6 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
|
||||
}
|
||||
|
||||
if (chatOptions?.ContinuationToken is not null && typedThread.ConversationId is null && typedThread.MessageStore is null)
|
||||
{
|
||||
throw new InvalidOperationException("Continuation tokens are not allowed to be used for initial runs.");
|
||||
}
|
||||
|
||||
List<ChatMessage> inputMessagesForChatClient = [];
|
||||
IList<ChatMessage>? aiContextProviderMessages = null;
|
||||
IList<ChatMessage>? chatMessageStoreMessages = null;
|
||||
@@ -713,7 +732,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
chatOptions.ConversationId = typedThread.ConversationId;
|
||||
}
|
||||
|
||||
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages);
|
||||
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages, continuationToken);
|
||||
}
|
||||
|
||||
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
|
||||
@@ -791,26 +810,43 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static void ValidateStreamResumptionAllowed(ResponseContinuationToken? continuationToken, ChatClientAgentThread safeThread)
|
||||
private static ChatClientAgentContinuationToken? WrapContinuationToken(ResponseContinuationToken? continuationToken, IEnumerable<ChatMessage>? inputMessages = null, List<ChatResponseUpdate>? responseUpdates = null)
|
||||
{
|
||||
if (continuationToken is null)
|
||||
{
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Streaming resumption is only supported with chat history managed by the agent service because, currently, there's no good solution
|
||||
// to collect updates received in failed runs and pass them to the last successful run so it can store them to the message store.
|
||||
if (safeThread.ConversationId is null)
|
||||
return new(continuationToken)
|
||||
{
|
||||
throw new NotSupportedException("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.");
|
||||
// Save input messages to the continuation token so they can be added to the thread and
|
||||
// provided to the context provider in the last successful streaming resumption run.
|
||||
// That's necessary for scenarios where initial streaming run is interrupted and streaming is resumed later.
|
||||
InputMessages = inputMessages?.Any() is true ? inputMessages : null,
|
||||
|
||||
// Save all updates received so far to the continuation token so they can be provided to the
|
||||
// message store and context provider in the last successful streaming resumption run.
|
||||
// That's necessary for scenarios where a streaming run is interrupted after some updates were received.
|
||||
ResponseUpdates = responseUpdates?.Count > 0 ? responseUpdates : null
|
||||
};
|
||||
}
|
||||
|
||||
private static IEnumerable<ChatMessage> GetInputMessages(IReadOnlyCollection<ChatMessage> inputMessages, ChatClientAgentContinuationToken? token)
|
||||
{
|
||||
// First, use input messages if provided.
|
||||
if (inputMessages.Count > 0)
|
||||
{
|
||||
return inputMessages;
|
||||
}
|
||||
|
||||
// Similarly, streaming resumption is not supported when a context provider is used because, currently, there's no good solution
|
||||
// to collect updates received in failed runs and pass them to the last successful run so it can notify the context provider of the updates.
|
||||
if (safeThread.AIContextProvider is not null)
|
||||
{
|
||||
throw new NotSupportedException("Using context provider with streaming resumption is not supported.");
|
||||
}
|
||||
// Fallback to messages saved in the continuation token if available.
|
||||
return token?.InputMessages ?? [];
|
||||
}
|
||||
|
||||
private static List<ChatResponseUpdate> GetResponseUpdates(ChatClientAgentContinuationToken? token)
|
||||
{
|
||||
// Restore any previously received updates from the continuation token.
|
||||
return token?.ResponseUpdates?.ToList() ?? [];
|
||||
}
|
||||
|
||||
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a continuation token for ChatClientAgent operations.
|
||||
/// </summary>
|
||||
internal class ChatClientAgentContinuationToken : ResponseContinuationToken
|
||||
{
|
||||
private const string TokenTypeName = "chatClientAgentContinuationToken";
|
||||
private const string TypeDiscriminator = "type";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatClientAgentContinuationToken"/> class.
|
||||
/// </summary>
|
||||
/// <param name="innerToken">A continuation token provided by the underlying <see cref="IChatClient"/>.</param>
|
||||
[JsonConstructor]
|
||||
internal ChatClientAgentContinuationToken(ResponseContinuationToken innerToken)
|
||||
{
|
||||
this.InnerToken = innerToken;
|
||||
}
|
||||
|
||||
public override ReadOnlyMemory<byte> ToBytes()
|
||||
{
|
||||
using MemoryStream stream = new();
|
||||
using Utf8JsonWriter writer = new(stream);
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
// This property should be the first one written to identify the type during deserialization.
|
||||
writer.WriteString(TypeDiscriminator, TokenTypeName);
|
||||
|
||||
writer.WriteString("innerToken", JsonSerializer.Serialize(this.InnerToken, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))));
|
||||
|
||||
if (this.InputMessages?.Any() is true)
|
||||
{
|
||||
writer.WriteString("inputMessages", JsonSerializer.Serialize(this.InputMessages, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>))));
|
||||
}
|
||||
|
||||
if (this.ResponseUpdates?.Count > 0)
|
||||
{
|
||||
writer.WriteString("responseUpdates", JsonSerializer.Serialize(this.ResponseUpdates, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList<ChatResponseUpdate>))));
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.Flush();
|
||||
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new instance of <see cref="ChatClientAgentContinuationToken"/> from the provided <paramref name="token"/>.
|
||||
/// </summary>
|
||||
/// <param name="token">The token to create the <see cref="ChatClientAgentContinuationToken"/> from.</param>
|
||||
/// <returns>A <see cref="ChatClientAgentContinuationToken"/> equivalent of the provided <paramref name="token"/>.</returns>
|
||||
internal static ChatClientAgentContinuationToken FromToken(ResponseContinuationToken token)
|
||||
{
|
||||
if (token is ChatClientAgentContinuationToken chatClientContinuationToken)
|
||||
{
|
||||
return chatClientContinuationToken;
|
||||
}
|
||||
|
||||
ReadOnlyMemory<byte> data = token.ToBytes();
|
||||
|
||||
if (data.Length == 0)
|
||||
{
|
||||
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain any data.");
|
||||
}
|
||||
|
||||
Utf8JsonReader reader = new(data.Span);
|
||||
|
||||
// Move to the start object token.
|
||||
_ = reader.Read();
|
||||
|
||||
// Validate that the token is of this type.
|
||||
ValidateTokenType(reader, token);
|
||||
|
||||
ResponseContinuationToken? innerToken = null;
|
||||
IEnumerable<ChatMessage>? inputMessages = null;
|
||||
IReadOnlyList<ChatResponseUpdate>? responseUpdates = null;
|
||||
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
switch (reader.GetString())
|
||||
{
|
||||
case "innerToken":
|
||||
_ = reader.Read();
|
||||
var innerTokenJson = reader.GetString() ?? throw new ArgumentException("No content for innerToken property.", nameof(token));
|
||||
innerToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(innerTokenJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
break;
|
||||
case "inputMessages":
|
||||
_ = reader.Read();
|
||||
var innerMessagesJson = reader.GetString() ?? throw new ArgumentException("No content for inputMessages property.", nameof(token));
|
||||
inputMessages = (IEnumerable<ChatMessage>?)JsonSerializer.Deserialize(innerMessagesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>)));
|
||||
break;
|
||||
case "responseUpdates":
|
||||
_ = reader.Read();
|
||||
var responseUpdatesJson = reader.GetString() ?? throw new ArgumentException("No content for responseUpdates property.", nameof(token));
|
||||
responseUpdates = (IReadOnlyList<ChatResponseUpdate>?)JsonSerializer.Deserialize(responseUpdatesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList<ChatResponseUpdate>)));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (innerToken is null)
|
||||
{
|
||||
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain an inner token.");
|
||||
}
|
||||
|
||||
return new ChatClientAgentContinuationToken(innerToken)
|
||||
{
|
||||
InputMessages = inputMessages,
|
||||
ResponseUpdates = responseUpdates
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateTokenType(Utf8JsonReader reader, ResponseContinuationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Move to the first property.
|
||||
_ = reader.Read();
|
||||
|
||||
// If the first property name is not "type", or its value does not match this token type name, then we know its not this token type.
|
||||
if (reader.GetString() != TypeDiscriminator || !reader.Read() || reader.GetString() != TokenTypeName)
|
||||
{
|
||||
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it is not of the correct type.");
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it could not be parsed.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a continuation token provided by the underlying <see cref="IChatClient"/>.
|
||||
/// </summary>
|
||||
internal ResponseContinuationToken InnerToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the input messages used for streaming run.
|
||||
/// </summary>
|
||||
internal IEnumerable<ChatMessage>? InputMessages { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the response updates received so far.
|
||||
/// </summary>
|
||||
internal IReadOnlyList<ChatResponseUpdate>? ResponseUpdates { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="ChatClientAgent"/> to enable discoverability of <see cref="ChatClientAgentRunOptions"/>.
|
||||
/// </summary>
|
||||
public partial class ChatClientAgent
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
|
||||
/// </summary>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation 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 represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
|
||||
public Task<AgentRunResponse> RunAsync(
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync(thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation 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 represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
|
||||
public Task<AgentRunResponse> RunAsync(
|
||||
string message,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a single chat message.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation 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 represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
|
||||
public Task<AgentRunResponse> RunAsync(
|
||||
ChatMessage message,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input messages and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation 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 represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
|
||||
public Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent in streaming mode without providing new input messages, relying on existing context and instructions.
|
||||
/// </summary>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
|
||||
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunStreamingAsync(thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent in streaming mode with a text message from the user.
|
||||
/// </summary>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
|
||||
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
string message,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent in streaming mode with a single chat message.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
|
||||
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
ChatMessage message,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent in streaming mode with a collection of chat messages.
|
||||
/// </summary>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input messages and any response updates generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
|
||||
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread,
|
||||
ChatClientAgentRunOptions? options,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunStreamingAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread, and requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </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 an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
|
||||
AgentThread? thread,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The user message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </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 an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
|
||||
string message,
|
||||
AgentThread? thread,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="message">The chat message to send to the agent.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input message and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </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 an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
|
||||
ChatMessage message,
|
||||
AgentThread? thread,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
|
||||
/// </summary>
|
||||
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
|
||||
/// <param name="thread">
|
||||
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
|
||||
/// The thread will be updated with the input messages and any response messages generated during invocation.
|
||||
/// </param>
|
||||
/// <param name="serializerOptions">The JSON serialization options to use.</param>
|
||||
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
|
||||
/// <param name="useJsonSchemaResponseFormat">
|
||||
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
|
||||
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
|
||||
/// </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 an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
|
||||
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentThread? thread,
|
||||
JsonSerializerOptions? serializerOptions,
|
||||
ChatClientAgentRunOptions? options,
|
||||
bool? useJsonSchemaResponseFormat = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
this.RunAsync<T>(messages, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
|
||||
}
|
||||
@@ -154,7 +154,10 @@ public sealed partial class ChatClientAgent
|
||||
|
||||
static ChatClientAgentRunResponse<T> CreateResponse(ChatResponse<T> chatResponse)
|
||||
{
|
||||
return new ChatClientAgentRunResponse<T>(chatResponse);
|
||||
return new ChatClientAgentRunResponse<T>(chatResponse)
|
||||
{
|
||||
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
|
||||
};
|
||||
}
|
||||
|
||||
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, thread, options, cancellationToken);
|
||||
|
||||
@@ -832,6 +832,174 @@ public sealed class A2AAgentTests : IDisposable
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponseToReturn = new AgentMessage
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response with metadata" }],
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
{ "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
|
||||
{ "responseCount", JsonSerializer.SerializeToElement(99) }
|
||||
}
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await this._agent.RunAsync(inputMessages);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result.AdditionalProperties);
|
||||
Assert.NotNull(result.AdditionalProperties["responseKey1"]);
|
||||
Assert.Equal("responseValue1", ((JsonElement)result.AdditionalProperties["responseKey1"]!).GetString());
|
||||
Assert.NotNull(result.AdditionalProperties["responseCount"]);
|
||||
Assert.Equal(99, ((JsonElement)result.AdditionalProperties["responseCount"]!).GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponseToReturn = new AgentMessage
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }]
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
AdditionalProperties = new()
|
||||
{
|
||||
{ "key1", "value1" },
|
||||
{ "key2", 42 },
|
||||
{ "key3", true }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await this._agent.RunAsync(inputMessages, null, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams);
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
|
||||
Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString());
|
||||
Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32());
|
||||
Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.ResponseToReturn = new AgentMessage
|
||||
{
|
||||
MessageId = "response-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Response" }]
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
AdditionalProperties = null
|
||||
};
|
||||
|
||||
// Act
|
||||
await this._agent.RunAsync(inputMessages, null, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams);
|
||||
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.StreamingResponseToReturn = new AgentMessage
|
||||
{
|
||||
MessageId = "stream-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Streaming response" }]
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test streaming message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
AdditionalProperties = new()
|
||||
{
|
||||
{ "streamKey1", "streamValue1" },
|
||||
{ "streamKey2", 100 },
|
||||
{ "streamKey3", false }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams);
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
|
||||
Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString());
|
||||
Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32());
|
||||
Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._handler.StreamingResponseToReturn = new AgentMessage
|
||||
{
|
||||
MessageId = "stream-123",
|
||||
Role = MessageRole.Agent,
|
||||
Parts = [new TextPart { Text = "Streaming response" }]
|
||||
};
|
||||
|
||||
var inputMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Test streaming message")
|
||||
};
|
||||
|
||||
var options = new AgentRunOptions
|
||||
{
|
||||
AdditionalProperties = null
|
||||
};
|
||||
|
||||
// Act
|
||||
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(this._handler.CapturedMessageSendParams);
|
||||
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AdditionalPropertiesDictionaryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = [];
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "numberKey", 42 }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" },
|
||||
{ "numberKey", 42 },
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
int[] arrayValue = [1, 2, 3];
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "arrayKey", arrayValue }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("arrayKey"));
|
||||
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
|
||||
Assert.Equal(3, result["arrayKey"].GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "nullKey", null! }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("nullKey"));
|
||||
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "jsonElementKey", jsonElement }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("jsonElementKey"));
|
||||
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
|
||||
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
|
||||
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public class AgentRunResponseTests
|
||||
RawRepresentation = new object(),
|
||||
ResponseId = "responseId",
|
||||
Usage = new UsageDetails(),
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
|
||||
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
|
||||
};
|
||||
|
||||
AgentRunResponse response = new(chatResponse);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using A2A;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
using Moq.Protected;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AIAgentExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = null
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1"),
|
||||
["key2"] = JsonSerializer.SerializeToElement(42)
|
||||
}
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
|
||||
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
|
||||
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
|
||||
/// because the ToAdditionalProperties extension method returns null for empty dictionaries.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
|
||||
Metadata = []
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.Null(capturedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProps = new()
|
||||
{
|
||||
["responseKey1"] = "responseValue1",
|
||||
["responseKey2"] = 123
|
||||
};
|
||||
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = additionalProps
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.NotNull(agentMessage.Metadata);
|
||||
Assert.Equal(2, agentMessage.Metadata.Count);
|
||||
Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
|
||||
Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
|
||||
Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
|
||||
Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = null
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.Null(agentMessage.Metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
|
||||
{
|
||||
AdditionalProperties = []
|
||||
};
|
||||
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
|
||||
|
||||
// Act
|
||||
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
|
||||
{
|
||||
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
|
||||
});
|
||||
|
||||
// Assert
|
||||
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
|
||||
Assert.Null(agentMessage.Metadata);
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentThread?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken>(
|
||||
(_, _, options, _) => optionsCallback(options))
|
||||
.ReturnsAsync(new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentRunResponse response)
|
||||
{
|
||||
Mock<AIAgent> agentMock = new() { CallBase = true };
|
||||
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
|
||||
agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
|
||||
agentMock
|
||||
.Protected()
|
||||
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentThread?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
|
||||
return agentMock;
|
||||
}
|
||||
|
||||
private static async Task<A2AResponse> InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
|
||||
{
|
||||
Func<MessageSendParams, CancellationToken, Task<A2AResponse>>? handler = taskManager.OnMessageReceived;
|
||||
Assert.NotNull(handler);
|
||||
return await handler.Invoke(messageSendParams, CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class TestAgentThread : AgentThread;
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI.Hosting.A2A.Converters;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
|
||||
/// </summary>
|
||||
public sealed class AdditionalPropertiesDictionaryExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary? additionalProperties = null;
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = [];
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "numberKey", 42 }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "stringKey", "stringValue" },
|
||||
{ "numberKey", 42 },
|
||||
{ "booleanKey", true }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(3, result.Count);
|
||||
|
||||
Assert.True(result.ContainsKey("stringKey"));
|
||||
Assert.Equal("stringValue", result["stringKey"].GetString());
|
||||
|
||||
Assert.True(result.ContainsKey("numberKey"));
|
||||
Assert.Equal(42, result["numberKey"].GetInt32());
|
||||
|
||||
Assert.True(result.ContainsKey("booleanKey"));
|
||||
Assert.True(result["booleanKey"].GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
int[] arrayValue = [1, 2, 3];
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "arrayKey", arrayValue }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("arrayKey"));
|
||||
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
|
||||
Assert.Equal(3, result["arrayKey"].GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "nullKey", null! }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("nullKey"));
|
||||
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
|
||||
{
|
||||
// Arrange
|
||||
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
|
||||
AdditionalPropertiesDictionary additionalProperties = new()
|
||||
{
|
||||
{ "jsonElementKey", jsonElement }
|
||||
};
|
||||
|
||||
// Act
|
||||
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result);
|
||||
Assert.True(result.ContainsKey("jsonElementKey"));
|
||||
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
|
||||
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
|
||||
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
|
||||
}
|
||||
}
|
||||
+12
-14
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -21,10 +22,8 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
int expectedMetadataCount)
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "testAgent", _ => new TestAgent("testAgent", "Test agent description") }
|
||||
};
|
||||
DurableAgentsOptions durableAgentsOptions = new();
|
||||
durableAgentsOptions.AddAIAgentFactory("testAgent", _ => new TestAgent("testAgent", "Test agent description"));
|
||||
|
||||
FunctionsAgentOptions options = new();
|
||||
|
||||
@@ -39,7 +38,7 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
List<IFunctionMetadata> metadataList = BuildFunctionMetadataList(initialMetadataEntryCount);
|
||||
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
durableAgentsOptions,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
@@ -74,12 +73,11 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
public void Transform_AddsTriggers_ForMultipleAgents()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, Func<IServiceProvider, AIAgent>> agents = new()
|
||||
{
|
||||
{ "agentA", _ => new TestAgent("testAgentA", "Test agent description") },
|
||||
{ "agentB", _ => new TestAgent("testAgentB", "Test agent description") },
|
||||
{ "agentC", _ => new TestAgent("testAgentC", "Test agent description") }
|
||||
};
|
||||
string[] agentNames = ["agentA", "agentB", "agentC"];
|
||||
DurableAgentsOptions durableAgentsOptions = new();
|
||||
durableAgentsOptions.AddAIAgentFactory("agentA", _ => new TestAgent("testAgentA", "Test agent description"));
|
||||
durableAgentsOptions.AddAIAgentFactory("agentB", _ => new TestAgent("testAgentB", "Test agent description"));
|
||||
durableAgentsOptions.AddAIAgentFactory("agentC", _ => new TestAgent("testAgentC", "Test agent description"));
|
||||
|
||||
// Helper to create options with configurable triggers
|
||||
static FunctionsAgentOptions CreateFunctionsAgentOptions(bool httpEnabled, bool mcpEnabled)
|
||||
@@ -103,7 +101,7 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
|
||||
IFunctionsAgentOptionsProvider agentOptionsProvider = new FakeOptionsProvider(functionsAgentOptions);
|
||||
DurableAgentFunctionMetadataTransformer transformer = new(
|
||||
agents,
|
||||
durableAgentsOptions,
|
||||
NullLogger<DurableAgentFunctionMetadataTransformer>.Instance,
|
||||
new FakeServiceProvider(),
|
||||
agentOptionsProvider);
|
||||
@@ -115,9 +113,9 @@ public sealed class DurableAgentFunctionMetadataTransformerTests
|
||||
transformer.Transform(metadataList);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(InitialMetadataEntryCount + (agents.Count * 2) + 2, metadataList.Count);
|
||||
Assert.Equal(InitialMetadataEntryCount + (agentNames.Length * 2) + 2, metadataList.Count);
|
||||
|
||||
foreach (string agentName in agents.Keys)
|
||||
foreach (string agentName in agentNames)
|
||||
{
|
||||
// The agent's entity trigger name is prefixed with "dafx-"
|
||||
DefaultFunctionMetadata entityMeta =
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.ChatClient;
|
||||
|
||||
public class ChatClientAgentContinuationTokenTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToBytes_Roundtrip()
|
||||
{
|
||||
// Arrange
|
||||
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
|
||||
{
|
||||
InputMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.User, "How are you?")
|
||||
],
|
||||
ResponseUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
ReadOnlyMemory<byte> bytes = chatClientToken.ToBytes();
|
||||
|
||||
ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(tokenFromBytes);
|
||||
Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
|
||||
|
||||
// Verify InnerToken
|
||||
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InputMessages
|
||||
Assert.NotNull(tokenFromBytes.InputMessages);
|
||||
Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
|
||||
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
|
||||
}
|
||||
|
||||
// Verify ResponseUpdates
|
||||
Assert.NotNull(tokenFromBytes.ResponseUpdates);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
|
||||
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialization_Roundtrip()
|
||||
{
|
||||
// Arrange
|
||||
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
|
||||
{
|
||||
InputMessages =
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Hello!"),
|
||||
new ChatMessage(ChatRole.User, "How are you?")
|
||||
],
|
||||
ResponseUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
|
||||
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
|
||||
ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
|
||||
|
||||
ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserializedChatClientToken);
|
||||
Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InnerToken
|
||||
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
|
||||
|
||||
// Verify InputMessages
|
||||
Assert.NotNull(deserializedChatClientToken.InputMessages);
|
||||
Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
|
||||
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
|
||||
}
|
||||
|
||||
// Verify ResponseUpdates
|
||||
Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
|
||||
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
|
||||
{
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
|
||||
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
|
||||
{
|
||||
// Arrange
|
||||
ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
|
||||
|
||||
// Act
|
||||
ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
|
||||
|
||||
// Assert
|
||||
Assert.Same(originalToken, fromToken);
|
||||
}
|
||||
}
|
||||
+274
-109
@@ -18,10 +18,10 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RunAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
public async Task RunAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
@@ -63,15 +63,15 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.True(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
|
||||
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
|
||||
public async Task RunAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
@@ -104,13 +104,13 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.False(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
|
||||
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task RunStreamingAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
public async Task RunStreamingAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
@@ -119,7 +119,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
@@ -164,11 +164,11 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
|
||||
Assert.True(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
|
||||
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
|
||||
public async Task RunStreamingAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
@@ -176,8 +176,8 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
|
||||
ChatOptions? capturedChatOptions = null;
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
@@ -212,11 +212,11 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
// Assert
|
||||
Assert.NotNull(capturedChatOptions);
|
||||
Assert.False(capturedChatOptions.AllowBackgroundResponses);
|
||||
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
|
||||
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncPropagatesContinuationTokenFromChatResponseToAgentRunResponseAsync()
|
||||
public async Task RunAsync_WhenContinuationTokenReceivedFromChatResponse_WrapsContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
@@ -237,11 +237,11 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Same(continuationToken, response.ContinuationToken);
|
||||
Assert.Same(continuationToken, (response.ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncPropagatesContinuationTokensFromUpdatesAsync()
|
||||
public async Task RunStreamingAsync_WhenContinuationTokenReceived_WrapsContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
|
||||
@@ -272,19 +272,19 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, actualUpdates.Count);
|
||||
Assert.Same(token1, actualUpdates[0].ContinuationToken);
|
||||
Assert.Same(token1, (actualUpdates[0].ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
|
||||
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
|
||||
public async Task RunAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
@@ -301,14 +301,14 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
|
||||
public async Task RunStreamingAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
|
||||
|
||||
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
|
||||
|
||||
@@ -331,7 +331,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
|
||||
public async Task RunAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
@@ -371,7 +371,10 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
AIContextProvider = mockContextProvider.Object
|
||||
};
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunAsync([], thread, options: runOptions);
|
||||
@@ -393,7 +396,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
|
||||
public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatMessage> capturedMessages = [];
|
||||
@@ -433,14 +436,15 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
AIContextProvider = mockContextProvider.Object
|
||||
};
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] }
|
||||
};
|
||||
|
||||
// Act
|
||||
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync());
|
||||
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message);
|
||||
|
||||
// With continuation token, thread message population should be skipped
|
||||
Assert.Empty(capturedMessages);
|
||||
|
||||
@@ -456,7 +460,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
|
||||
public async Task RunAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
@@ -480,7 +484,7 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
|
||||
public async Task RunStreamingAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
@@ -510,126 +514,287 @@ public class ChatClientAgent_BackgroundResponsesTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync()
|
||||
public async Task RunStreamingAsync_WhenInputMessagesPresentInContinuationToken_ResumesStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
// Create a new thread with no ConversationId and no MessageStore (initial run state)
|
||||
ChatClientAgentThread thread = new();
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(thread: thread, options: runOptions));
|
||||
Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message);
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetResponseAsync(
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
// Create a new thread with no ConversationId and no MessageStore (initial run state)
|
||||
ChatClientAgentThread thread = new();
|
||||
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
|
||||
Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message);
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithClientSideManagedChatHistoryAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
// Create a thread with a MessageStore
|
||||
ChatClientAgentThread thread = new()
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
MessageStore = new InMemoryChatMessageStore(), // Setting a message store to skip checking the continuation token in the initial run
|
||||
ConversationId = null, // No conversation ID to simulate client-side managed chat history
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
InputMessages = [new ChatMessage(ChatRole.User, "previous message")]
|
||||
}
|
||||
};
|
||||
|
||||
// Create run options with a continuation token
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
|
||||
Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message);
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
// Verify that the IChatClient was called
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithAIContextProviderAsync()
|
||||
public async Task RunStreamingAsync_WhenResponseUpdatesPresentInContinuationToken_ResumesStreamingAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
// Create a mock AIContextProvider
|
||||
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "previous update")]
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Single(updates);
|
||||
|
||||
// Verify that the IChatClient was called
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndMessageStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "upon"),
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a"),
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"),
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToStore = [];
|
||||
var mockMessageStore = new Mock<ChatMessageStore>();
|
||||
mockMessageStore
|
||||
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.ResponseMessages ?? []))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider
|
||||
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new AIContext());
|
||||
mockContextProvider
|
||||
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
|
||||
.Returns(new ValueTask());
|
||||
|
||||
// Create a thread with an AIContextProvider and conversation ID to simulate non-initial run
|
||||
ChatClientAgentThread thread = new()
|
||||
{
|
||||
ConversationId = "existing-conversation-id",
|
||||
MessageStore = mockMessageStore.Object,
|
||||
AIContextProvider = mockContextProvider.Object
|
||||
};
|
||||
|
||||
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "once ")]
|
||||
}
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
|
||||
// Act
|
||||
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
|
||||
|
||||
Assert.Equal("Using context provider with streaming resumption is not supported.", exception.Message);
|
||||
// Assert
|
||||
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Single(capturedMessagesAddedToStore);
|
||||
Assert.Contains("once upon a time", capturedMessagesAddedToStore[0].Text);
|
||||
|
||||
// Verify that the IChatClient was never called due to early validation
|
||||
mockChatClient.Verify(
|
||||
c => c.GetStreamingResponseAsync(
|
||||
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.NotNull(capturedInvokedContext?.ResponseMessages);
|
||||
Assert.Single(capturedInvokedContext.ResponseMessages);
|
||||
Assert.Contains("once upon a time", capturedInvokedContext.ResponseMessages.ElementAt(0).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromInitialRunForContextProviderAndMessageStoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
List<ChatMessage> capturedMessagesAddedToStore = [];
|
||||
var mockMessageStore = new Mock<ChatMessageStore>();
|
||||
mockMessageStore
|
||||
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.RequestMessages))
|
||||
.Returns(new ValueTask());
|
||||
|
||||
AIContextProvider.InvokedContext? capturedInvokedContext = null;
|
||||
var mockContextProvider = new Mock<AIContextProvider>();
|
||||
mockContextProvider
|
||||
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
|
||||
.Returns(new ValueTask());
|
||||
|
||||
ChatClientAgentThread thread = new()
|
||||
{
|
||||
MessageStore = mockMessageStore.Object,
|
||||
AIContextProvider = mockContextProvider.Object
|
||||
};
|
||||
|
||||
AgentRunOptions runOptions = new()
|
||||
{
|
||||
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
|
||||
{
|
||||
InputMessages = [new ChatMessage(ChatRole.User, "Tell me a story")],
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
|
||||
|
||||
// Assert
|
||||
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Single(capturedMessagesAddedToStore);
|
||||
Assert.Contains("Tell me a story", capturedMessagesAddedToStore[0].Text);
|
||||
|
||||
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.NotNull(capturedInvokedContext?.RequestMessages);
|
||||
Assert.Single(capturedInvokedContext.RequestMessages);
|
||||
Assert.Contains("Tell me a story", capturedInvokedContext.RequestMessages.ElementAt(0).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WhenResumingStreaming_SavesInputMessagesAndUpdatesInContinuationTokenAsync()
|
||||
{
|
||||
// Arrange
|
||||
List<ChatResponseUpdate> returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Once") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " upon") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"){ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
|
||||
];
|
||||
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient
|
||||
.Setup(c => c.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(ToAsyncEnumerableAsync(returnUpdates));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
|
||||
ChatClientAgentThread thread = new() { };
|
||||
|
||||
List<ChatClientAgentContinuationToken> capturedContinuationTokens = [];
|
||||
|
||||
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
|
||||
|
||||
// Act
|
||||
|
||||
// Do the initial run
|
||||
await foreach (var update in agent.RunStreamingAsync(userMessage, thread))
|
||||
{
|
||||
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
|
||||
break;
|
||||
}
|
||||
|
||||
// Now resume the run using the captured continuation token
|
||||
returnUpdates.RemoveAt(0); // remove the first mock update as it was already processed
|
||||
var options = new AgentRunOptions { ContinuationToken = capturedContinuationTokens[0] };
|
||||
await foreach (var update in agent.RunStreamingAsync(thread, options: options))
|
||||
{
|
||||
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(4, capturedContinuationTokens.Count);
|
||||
|
||||
// Verify that the first continuation token has the initial input and first update
|
||||
Assert.NotNull(capturedContinuationTokens[0].InputMessages);
|
||||
Assert.Single(capturedContinuationTokens[0].InputMessages!);
|
||||
Assert.Equal("Tell me a story", capturedContinuationTokens[0].InputMessages!.Last().Text);
|
||||
Assert.NotNull(capturedContinuationTokens[0].ResponseUpdates);
|
||||
Assert.Single(capturedContinuationTokens[0].ResponseUpdates!);
|
||||
Assert.Equal("Once", capturedContinuationTokens[0].ResponseUpdates![0].Text);
|
||||
|
||||
// Verify the last continuation token has the input and all updates
|
||||
var lastToken = capturedContinuationTokens[^1];
|
||||
Assert.NotNull(lastToken.InputMessages);
|
||||
Assert.Single(lastToken.InputMessages!);
|
||||
Assert.Equal("Tell me a story", lastToken.InputMessages!.Last().Text);
|
||||
Assert.NotNull(lastToken.ResponseUpdates);
|
||||
Assert.Equal(4, lastToken.ResponseUpdates!.Count);
|
||||
Assert.Equal("Once", lastToken.ResponseUpdates!.ElementAt(0).Text);
|
||||
Assert.Equal(" upon", lastToken.ResponseUpdates!.ElementAt(1).Text);
|
||||
Assert.Equal(" a", lastToken.ResponseUpdates!.ElementAt(2).Text);
|
||||
Assert.Equal(" time", lastToken.ResponseUpdates!.ElementAt(3).Text);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ChatClientAgent"/> run methods with <see cref="ChatClientAgentRunOptions"/>.
|
||||
/// </summary>
|
||||
public sealed partial class ChatClientAgent_RunWithCustomOptionsTests
|
||||
{
|
||||
#region RunAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithThreadAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse result = await agent.RunAsync(thread, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse result = await agent.RunAsync("Test message", thread, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse result = await agent.RunAsync(message, thread, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse result = await agent.RunAsync(messages, thread, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Single(result.Messages);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_WithChatOptionsInRunOptions_UsesChatOptionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions { Temperature = 0.5f });
|
||||
|
||||
// Act
|
||||
AgentRunResponse result = await agent.RunAsync("Test", null, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.Is<ChatOptions>(opts => opts.Temperature == 0.5f),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunStreamingAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithThreadAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(thread, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync("Test message", thread, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(message, thread, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Message 1"), new ChatMessage(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
var updates = new List<AgentRunResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync(messages, thread, options))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static async IAsyncEnumerable<ChatResponseUpdate> GetAsyncUpdatesAsync()
|
||||
{
|
||||
yield return new ChatResponseUpdate { Contents = new[] { new TextContent("Hello") } };
|
||||
yield return new ChatResponseUpdate { Contents = new[] { new TextContent(" World") } };
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RunAsync{T} Tests
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithThreadAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(thread, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentRunResponse);
|
||||
Assert.Single(agentRunResponse.Messages);
|
||||
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithStringMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>("Test message", thread, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentRunResponse);
|
||||
Assert.Single(agentRunResponse.Messages);
|
||||
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithChatMessageAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
ChatMessage message = new(ChatRole.User, "Test message");
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(message, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentRunResponse);
|
||||
Assert.Single(agentRunResponse.Messages);
|
||||
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsyncOfT_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockChatClient = new();
|
||||
mockChatClient.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
|
||||
|
||||
ChatClientAgent agent = new(mockChatClient.Object);
|
||||
AgentThread thread = agent.GetNewThread();
|
||||
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
|
||||
ChatClientAgentRunOptions options = new();
|
||||
|
||||
// Act
|
||||
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(messages, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agentRunResponse);
|
||||
Assert.Single(agentRunResponse.Messages);
|
||||
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
|
||||
mockChatClient.Verify(
|
||||
x => x.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class Animal
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? FullName { get; set; }
|
||||
public Species Species { get; set; }
|
||||
}
|
||||
|
||||
private enum Species
|
||||
{
|
||||
Bear,
|
||||
Tiger,
|
||||
Walrus,
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(Animal))]
|
||||
private sealed partial class JsonContext_WithCustomRunOptions : JsonSerializerContext;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public sealed class ExpectedException : Exception
|
||||
{
|
||||
public ExpectedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ExpectedException() : base()
|
||||
{
|
||||
}
|
||||
|
||||
public ExpectedException(string? message, Exception? innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public class WorkflowHostSmokeTests
|
||||
{
|
||||
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
|
||||
{
|
||||
private sealed class Thread : InMemoryAgentThread
|
||||
{
|
||||
public Thread() { }
|
||||
|
||||
public Thread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: base(serializedThread, jsonSerializerOptions)
|
||||
{ }
|
||||
}
|
||||
|
||||
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
return new Thread(serializedThread, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public override AgentThread GetNewThread()
|
||||
{
|
||||
return new Thread();
|
||||
}
|
||||
|
||||
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this.RunStreamingAsync(messages, thread, options, cancellationToken)
|
||||
.ToAgentRunResponseAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
const string ErrorMessage = "Simulated agent failure.";
|
||||
if (failByThrowing)
|
||||
{
|
||||
throw new ExpectedException(ErrorMessage);
|
||||
}
|
||||
|
||||
yield return new AgentRunResponseUpdate(ChatRole.Assistant, [new ErrorContent(ErrorMessage)]);
|
||||
}
|
||||
}
|
||||
|
||||
private static Workflow CreateWorkflow(bool failByThrowing)
|
||||
{
|
||||
ExecutorBinding agent = new AlwaysFailsAIAgent(failByThrowing).BindAsExecutor(emitEvents: true);
|
||||
|
||||
return new WorkflowBuilder(agent).Build();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, true)]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
[InlineData(false, false)]
|
||||
public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptionDetails, bool failByThrowing)
|
||||
{
|
||||
string expectedMessage = !failByThrowing || includeExceptionDetails
|
||||
? "Simulated agent failure."
|
||||
: "An error occurred while executing the workflow.";
|
||||
|
||||
// Arrange is done by the caller.
|
||||
Workflow workflow = CreateWorkflow(failByThrowing);
|
||||
|
||||
// Act
|
||||
List<AgentRunResponseUpdate> updates = await workflow.AsAgent("WorkflowAgent", includeExceptionDetails: includeExceptionDetails)
|
||||
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
|
||||
.ToListAsync();
|
||||
|
||||
// Assert
|
||||
bool hadErrorContent = false;
|
||||
foreach (AgentRunResponseUpdate update in updates)
|
||||
{
|
||||
if (update.Contents.Any())
|
||||
{
|
||||
// We should expect a single update which contains the error content.
|
||||
update.Contents.Should().ContainSingle()
|
||||
.Which.Should().BeOfType<ErrorContent>()
|
||||
.Which.Message.Should().Be(expectedMessage);
|
||||
hadErrorContent = true;
|
||||
}
|
||||
}
|
||||
|
||||
hadErrorContent.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0b260107] - 2026-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- **agent-framework-devui**: Improve DevUI and add Context Inspector view as a new tab under traces ([#2742](https://github.com/microsoft/agent-framework/pull/2742)) by @victordibia
|
||||
- **samples**: Add streaming sample for Azure Functions ([#3057](https://github.com/microsoft/agent-framework/pull/3057)) by @gavin-aguiar
|
||||
|
||||
### Changed
|
||||
|
||||
- **repo**: Update templates ([#3106](https://github.com/microsoft/agent-framework/pull/3106)) by @eavanvalkenburg
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-ag-ui**: Fix MCP tool result serialization for list[TextContent] ([#2523](https://github.com/microsoft/agent-framework/pull/2523)) by @claude89757
|
||||
- **agent-framework-azure-ai**: Fix response_format handling for structured outputs ([#3114](https://github.com/microsoft/agent-framework/pull/3114)) by @moonbox3
|
||||
|
||||
## [1.0.0b260106] - 2026-01-06
|
||||
|
||||
### Added
|
||||
@@ -445,7 +461,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260107...HEAD
|
||||
[1.0.0b260107]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...python-1.0.0b260107
|
||||
[1.0.0b260106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...python-1.0.0b260106
|
||||
[1.0.0b251223]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251218...python-1.0.0b251223
|
||||
[1.0.0b251218]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...python-1.0.0b251218
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260106"
|
||||
version = "1.0.0b260107"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -11,21 +11,61 @@ from typing import Any
|
||||
|
||||
|
||||
class ConfirmationStrategy(ABC):
|
||||
"""Strategy for generating confirmation messages during human-in-the-loop flows."""
|
||||
"""Strategy for generating confirmation messages during human-in-the-loop flows.
|
||||
|
||||
Subclasses must define the message properties. The methods use those properties
|
||||
by default, but can be overridden for complete customization.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def approval_header(self) -> str:
|
||||
"""Header for approval accepted message. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def approval_footer(self) -> str:
|
||||
"""Footer for approval accepted message. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def rejection_message(self) -> str:
|
||||
"""Message when user rejects. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def state_confirmed_message(self) -> str:
|
||||
"""Message when state is confirmed. Must be overridden."""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def state_rejected_message(self) -> str:
|
||||
"""Message when state is rejected. Must be overridden."""
|
||||
...
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user approves function execution.
|
||||
|
||||
Default implementation uses header/footer properties.
|
||||
Override for complete customization.
|
||||
|
||||
Args:
|
||||
steps: List of approved steps with 'description', 'status', etc.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
message_parts = [self.approval_header.format(count=len(enabled_steps))]
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
message_parts.append(self.approval_footer)
|
||||
return "".join(message_parts)
|
||||
|
||||
@abstractmethod
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate message when user rejects function execution.
|
||||
|
||||
@@ -35,141 +75,143 @@ class ConfirmationStrategy(ABC):
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.rejection_message
|
||||
|
||||
@abstractmethod
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate message when user confirms predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.state_confirmed_message
|
||||
|
||||
@abstractmethod
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate message when user rejects predictive state changes.
|
||||
|
||||
Returns:
|
||||
Message to display to user
|
||||
"""
|
||||
...
|
||||
return self.state_rejected_message
|
||||
|
||||
|
||||
class DefaultConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Generic confirmation messages suitable for most agents.
|
||||
"""Generic confirmation messages suitable for most agents."""
|
||||
|
||||
This preserves the original behavior from v1.
|
||||
"""
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Executing {count} approved steps:\n\n"
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic approval message with step list."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nAll steps completed successfully!"
|
||||
|
||||
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll steps completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate generic rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! What would you like me to change about the plan?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate generic state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Changes confirmed and applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate generic state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What would you like me to change?"
|
||||
|
||||
|
||||
class TaskPlannerConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for task planning agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Executing your requested tasks:\n\n"
|
||||
|
||||
message_parts = ["Executing your requested tasks:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nAll tasks completed successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nAll tasks completed successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate task-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! Let me revise the plan. What would you like me to change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Tasks confirmed and ready to execute!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Task planners typically don't use state confirmation."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! How should I adjust the task list?"
|
||||
|
||||
|
||||
class RecipeConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for recipe agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Updating your recipe:\n\n"
|
||||
|
||||
message_parts = ["Updating your recipe:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nRecipe updated successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nRecipe updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate recipe-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! What ingredients or steps should I change?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate recipe-specific state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Recipe changes applied successfully!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate recipe-specific state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What would you like me to adjust in the recipe?"
|
||||
|
||||
|
||||
class DocumentWriterConfirmationStrategy(ConfirmationStrategy):
|
||||
"""Domain-specific confirmation messages for document writing agents."""
|
||||
|
||||
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific approval message."""
|
||||
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
|
||||
@property
|
||||
def approval_header(self) -> str:
|
||||
return "Applying your edits:\n\n"
|
||||
|
||||
message_parts = ["Applying your edits:\n\n"]
|
||||
@property
|
||||
def approval_footer(self) -> str:
|
||||
return "\nDocument updated successfully!"
|
||||
|
||||
for i, step in enumerate(enabled_steps, 1):
|
||||
message_parts.append(f"{i}. {step['description']}\n")
|
||||
|
||||
message_parts.append("\nDocument updated successfully!")
|
||||
|
||||
return "".join(message_parts)
|
||||
|
||||
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
|
||||
"""Generate document-specific rejection message."""
|
||||
@property
|
||||
def rejection_message(self) -> str:
|
||||
return "No problem! Which changes should I keep or modify?"
|
||||
|
||||
def on_state_confirmed(self) -> str:
|
||||
"""Generate document-specific state confirmation message."""
|
||||
@property
|
||||
def state_confirmed_message(self) -> str:
|
||||
return "Document edits applied!"
|
||||
|
||||
def on_state_rejected(self) -> str:
|
||||
"""Generate document-specific state rejection message."""
|
||||
@property
|
||||
def state_rejected_message(self) -> str:
|
||||
return "No problem! What should I change about the document?"
|
||||
|
||||
|
||||
def apply_confirmation_strategy(
|
||||
strategy: ConfirmationStrategy | None,
|
||||
accepted: bool,
|
||||
steps: list[dict[str, Any]],
|
||||
) -> str:
|
||||
"""Apply a confirmation strategy to generate a message.
|
||||
|
||||
This helper consolidates the pattern used in multiple orchestrators.
|
||||
|
||||
Args:
|
||||
strategy: Strategy to use, or None for default
|
||||
accepted: Whether the user approved
|
||||
steps: List of steps (may be empty for state confirmations)
|
||||
|
||||
Returns:
|
||||
Generated message string
|
||||
"""
|
||||
if strategy is None:
|
||||
strategy = DefaultConfirmationStrategy()
|
||||
|
||||
if not steps:
|
||||
# State confirmation (no steps)
|
||||
return strategy.on_state_confirmed() if accepted else strategy.on_state_rejected()
|
||||
# Step-based approval
|
||||
return strategy.on_approval_accepted(steps) if accepted else strategy.on_approval_rejected(steps)
|
||||
|
||||
@@ -11,8 +11,6 @@ from typing import Any
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
EventType,
|
||||
MessagesSnapshotEvent,
|
||||
RunFinishedEvent,
|
||||
RunStartedEvent,
|
||||
StateDeltaEvent,
|
||||
@@ -34,7 +32,7 @@ from agent_framework import (
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
from ._utils import generate_event_id
|
||||
from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,8 +47,8 @@ class AgentFrameworkEventBridge:
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
current_state: dict[str, Any] | None = None,
|
||||
skip_text_content: bool = False,
|
||||
input_messages: list[Any] | None = None,
|
||||
require_confirmation: bool = True,
|
||||
approval_tool_name: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize the event bridge.
|
||||
@@ -62,7 +60,6 @@ class AgentFrameworkEventBridge:
|
||||
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
|
||||
current_state: Reference to the current state dict for tracking updates.
|
||||
skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs).
|
||||
input_messages: The input messages from the conversation history.
|
||||
require_confirmation: Whether predictive state updates require user confirmation.
|
||||
"""
|
||||
self.run_id = run_id
|
||||
@@ -75,6 +72,7 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls
|
||||
self.skip_text_content = skip_text_content
|
||||
self.require_confirmation = require_confirmation
|
||||
self.approval_tool_name = approval_tool_name
|
||||
|
||||
# For predictive state updates: accumulate streaming arguments
|
||||
self.streaming_tool_args: str = "" # Accumulated JSON string
|
||||
@@ -83,13 +81,6 @@ class AgentFrameworkEventBridge:
|
||||
self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes
|
||||
self.suppressed_summary: str = "" # Store LLM summary to show after confirmation
|
||||
|
||||
# For MessagesSnapshotEvent: track tool calls and results
|
||||
self.input_messages = input_messages or []
|
||||
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
|
||||
self.tool_results: list[dict[str, Any]] = [] # Track tool results
|
||||
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
|
||||
self.accumulated_text_content: str = "" # Track accumulated text for final MessagesSnapshotEvent
|
||||
|
||||
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
|
||||
"""
|
||||
Convert an AgentRunResponseUpdate to AG-UI events.
|
||||
@@ -155,7 +146,6 @@ class AgentFrameworkEventBridge:
|
||||
message_id=self.current_message_id,
|
||||
delta=content.text,
|
||||
)
|
||||
self.accumulated_text_content += content.text
|
||||
logger.info(f" EMITTING TextMessageContentEvent with text_len={len(content.text)}")
|
||||
events.append(event)
|
||||
return events
|
||||
@@ -170,10 +160,10 @@ class AgentFrameworkEventBridge:
|
||||
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
|
||||
|
||||
tool_call_id = self._coalesce_tool_call_id(content)
|
||||
# Only emit ToolCallStartEvent once per tool call (when it's a new tool call)
|
||||
if content.name and tool_call_id != self.current_tool_call_id:
|
||||
self.streaming_tool_args = ""
|
||||
self.state_delta_count = 0
|
||||
if content.name:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
self.current_tool_call_name = content.name
|
||||
|
||||
@@ -184,17 +174,6 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
|
||||
events.append(tool_start_event)
|
||||
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": content.name,
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
)
|
||||
elif tool_call_id:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
|
||||
@@ -207,13 +186,7 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
events.append(args_event)
|
||||
|
||||
for tool_call in self.pending_tool_calls:
|
||||
if tool_call["id"] == tool_call_id:
|
||||
tool_call["function"]["arguments"] += delta_str
|
||||
break
|
||||
|
||||
events.extend(self._emit_predictive_state_deltas(delta_str))
|
||||
events.extend(self._legacy_predictive_state(content))
|
||||
|
||||
return events
|
||||
|
||||
@@ -236,10 +209,8 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_name,
|
||||
)
|
||||
|
||||
parsed_args = None
|
||||
try:
|
||||
parsed_args = json.loads(self.streaming_tool_args)
|
||||
except json.JSONDecodeError:
|
||||
parsed_args = safe_json_parse(self.streaming_tool_args)
|
||||
if parsed_args is None:
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != self.current_tool_call_name:
|
||||
continue
|
||||
@@ -283,11 +254,8 @@ class AgentFrameworkEventBridge:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
|
||||
if state_value is None:
|
||||
continue
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
|
||||
@@ -318,59 +286,6 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
return events
|
||||
|
||||
def _legacy_predictive_state(self, content: FunctionCallContent) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if not (content.name and content.arguments):
|
||||
return events
|
||||
parsed_args = content.parse_arguments()
|
||||
if not parsed_args:
|
||||
return events
|
||||
|
||||
logger.info(
|
||||
"Checking predict_state_config keys: %s",
|
||||
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
|
||||
)
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
logger.info(f"Checking state_key='{state_key}'")
|
||||
if config["tool"] != content.name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
logger.info(f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'")
|
||||
|
||||
state_value: Any
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
logger.info(f"Using all args as state value, keys: {list(state_value.keys())}")
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
logger.info(f"Using specific arg '{tool_arg_name}' as state value")
|
||||
else:
|
||||
logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
previous_value = self.last_emitted_state.get(state_key, object())
|
||||
if previous_value == state_value:
|
||||
logger.info(
|
||||
"Skipping duplicate StateDeltaEvent for key '%s' - value unchanged",
|
||||
state_key,
|
||||
)
|
||||
continue
|
||||
|
||||
state_delta_event = StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/{state_key}",
|
||||
"value": state_value,
|
||||
}
|
||||
],
|
||||
)
|
||||
logger.info(f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}") # type: ignore
|
||||
events.append(state_delta_event)
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
self.last_emitted_state[state_key] = state_value
|
||||
return events
|
||||
|
||||
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if content.call_id:
|
||||
@@ -379,7 +294,6 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.call_id)
|
||||
|
||||
if self.state_delta_count > 0:
|
||||
logger.info(
|
||||
@@ -401,55 +315,10 @@ class AgentFrameworkEventBridge:
|
||||
role="tool",
|
||||
)
|
||||
events.append(result_event)
|
||||
|
||||
self.tool_results.append(
|
||||
{
|
||||
"id": result_message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
|
||||
events.extend(self._emit_snapshot_for_tool_result())
|
||||
events.extend(self._emit_state_snapshot_and_confirmation())
|
||||
|
||||
return events
|
||||
|
||||
def _emit_snapshot_for_tool_result(self) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
should_emit_snapshot = self.pending_tool_calls and self.tool_results
|
||||
|
||||
is_predictive_without_confirmation = False
|
||||
if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config:
|
||||
for _, config in self.predict_state_config.items():
|
||||
if config["tool"] == self.current_tool_call_name and not self.require_confirmation:
|
||||
is_predictive_without_confirmation = True
|
||||
logger.info(
|
||||
"Skipping intermediate MessagesSnapshotEvent for predictive tool '%s' - delaying until summary",
|
||||
self.current_tool_call_name,
|
||||
)
|
||||
break
|
||||
|
||||
if should_emit_snapshot and not is_predictive_without_confirmation:
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(),
|
||||
}
|
||||
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
|
||||
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
|
||||
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT,
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
)
|
||||
logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
|
||||
events.append(messages_snapshot_event)
|
||||
return events
|
||||
|
||||
def _emit_state_snapshot_and_confirmation(self) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if self.pending_state_updates:
|
||||
@@ -498,31 +367,46 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_name = None
|
||||
return events
|
||||
|
||||
def _emit_confirm_changes_tool_call(self) -> list[BaseEvent]:
|
||||
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
|
||||
"""Emit a confirm_changes tool call for Dojo UI compatibility.
|
||||
|
||||
Args:
|
||||
function_call: Optional function call that needs confirmation.
|
||||
If provided, includes function info in the confirm_changes args
|
||||
so Dojo UI can display what's being confirmed.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
confirm_call_id = generate_event_id()
|
||||
logger.info("Emitting confirm_changes tool call for predictive update")
|
||||
|
||||
self.pending_tool_calls.append(
|
||||
{
|
||||
"id": confirm_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "confirm_changes",
|
||||
"arguments": "{}",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
confirm_start = ToolCallStartEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
tool_call_name="confirm_changes",
|
||||
parent_message_id=self.current_message_id,
|
||||
)
|
||||
events.append(confirm_start)
|
||||
|
||||
# Include function info if this is for a function approval
|
||||
# This helps Dojo UI display meaningful confirmation info
|
||||
if function_call:
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
"status": "enabled",
|
||||
}
|
||||
],
|
||||
}
|
||||
args_json = json.dumps(args_dict)
|
||||
else:
|
||||
args_json = "{}"
|
||||
|
||||
confirm_args = ToolCallArgsEvent(
|
||||
tool_call_id=confirm_call_id,
|
||||
delta="{}",
|
||||
delta=args_json,
|
||||
)
|
||||
events.append(confirm_args)
|
||||
|
||||
@@ -531,23 +415,48 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
events.append(confirm_end)
|
||||
|
||||
from ._message_adapters import agent_framework_messages_to_agui
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
return events
|
||||
|
||||
assistant_message = {
|
||||
"id": generate_event_id(),
|
||||
"role": "assistant",
|
||||
"tool_calls": self.pending_tool_calls.copy(),
|
||||
}
|
||||
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
|
||||
"""Emit a tool call that can drive UI approval for function requests."""
|
||||
tool_call_name = "confirm_changes"
|
||||
if self.approval_tool_name and self.approval_tool_name != function_call.name:
|
||||
tool_call_name = self.approval_tool_name
|
||||
|
||||
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
|
||||
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
|
||||
tool_call_id = generate_event_id()
|
||||
tool_start = ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=tool_call_name,
|
||||
parent_message_id=self.current_message_id,
|
||||
)
|
||||
events: list[BaseEvent] = [tool_start]
|
||||
|
||||
messages_snapshot_event = MessagesSnapshotEvent(
|
||||
type=EventType.MESSAGES_SNAPSHOT,
|
||||
messages=all_messages, # type: ignore[arg-type]
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
"status": "enabled",
|
||||
}
|
||||
],
|
||||
}
|
||||
args_json = json.dumps(args_dict)
|
||||
|
||||
events.append(
|
||||
ToolCallArgsEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
delta=args_json,
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
ToolCallEndEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
)
|
||||
logger.info(f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages")
|
||||
events.append(messages_snapshot_event)
|
||||
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
@@ -579,12 +488,8 @@ class AgentFrameworkEventBridge:
|
||||
tool_arg_name,
|
||||
)
|
||||
|
||||
state_value: Any
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
|
||||
if state_value is None:
|
||||
logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args")
|
||||
continue
|
||||
|
||||
@@ -601,8 +506,8 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
|
||||
events.append(end_event)
|
||||
self.tool_calls_ended.add(content.function_call.call_id)
|
||||
|
||||
# Emit the function_approval_request custom event for UI implementations that support it
|
||||
approval_event = CustomEvent(
|
||||
name="function_approval_request",
|
||||
value={
|
||||
@@ -616,6 +521,14 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
|
||||
events.append(approval_event)
|
||||
|
||||
# Emit a UI-friendly approval tool call for function approvals.
|
||||
if self.require_confirmation:
|
||||
events.extend(self._emit_function_approval_tool_call(content.function_call))
|
||||
|
||||
# Signal orchestrator to stop the run and wait for user approval response
|
||||
self.should_stop_after_confirm = True
|
||||
logger.info("Set flag to stop run - waiting for function approval response")
|
||||
return events
|
||||
|
||||
def create_run_started_event(self) -> RunStartedEvent:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"""Message format conversion between AG-UI and Agent Framework."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
@@ -15,18 +16,226 @@ from agent_framework import (
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
# Role mapping constants
|
||||
_AGUI_TO_FRAMEWORK_ROLE = {
|
||||
"user": Role.USER,
|
||||
"assistant": Role.ASSISTANT,
|
||||
"system": Role.SYSTEM,
|
||||
}
|
||||
from ._utils import (
|
||||
AGUI_TO_FRAMEWORK_ROLE,
|
||||
FRAMEWORK_TO_AGUI_ROLE,
|
||||
get_role_value,
|
||||
normalize_agui_role,
|
||||
safe_json_parse,
|
||||
)
|
||||
|
||||
_FRAMEWORK_TO_AGUI_ROLE = {
|
||||
Role.USER: "user",
|
||||
Role.ASSISTANT: "assistant",
|
||||
Role.SYSTEM: "system",
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
|
||||
sanitized: list[ChatMessage] = []
|
||||
pending_tool_call_ids: set[str] | None = None
|
||||
pending_confirm_changes_id: str | None = None
|
||||
|
||||
for msg in messages:
|
||||
role_value = get_role_value(msg)
|
||||
|
||||
if role_value == "assistant":
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
}
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = tool_ids if tool_ids else None
|
||||
pending_confirm_changes_id = (
|
||||
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
|
||||
)
|
||||
continue
|
||||
|
||||
if role_value == "user":
|
||||
approval_call_ids: set[str] = set()
|
||||
approval_accepted: bool | None = None
|
||||
for content in msg.contents or []:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if content.function_call and content.function_call.call_id:
|
||||
approval_call_ids.add(str(content.function_call.call_id))
|
||||
if approval_accepted is None:
|
||||
approval_accepted = bool(content.approved)
|
||||
else:
|
||||
approval_accepted = approval_accepted and bool(content.approved)
|
||||
|
||||
if approval_call_ids and pending_tool_call_ids:
|
||||
pending_tool_call_ids -= approval_call_ids
|
||||
logger.info(
|
||||
f"FunctionApprovalResponseContent found for call_ids={sorted(approval_call_ids)} - "
|
||||
"framework will handle execution"
|
||||
)
|
||||
|
||||
if pending_confirm_changes_id and approval_accepted is not None:
|
||||
logger.info(f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if approval_accepted else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
if pending_confirm_changes_id:
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
if "accepted" in parsed:
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
)
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
logger.debug(f"Could not parse user message as confirm_changes response: {type(exc).__name__}")
|
||||
|
||||
if pending_tool_call_ids:
|
||||
logger.info(
|
||||
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - "
|
||||
"injecting synthetic results"
|
||||
)
|
||||
for pending_call_id in pending_tool_call_ids:
|
||||
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_call_id,
|
||||
result="Tool execution skipped - user provided follow-up message",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
|
||||
if role_value == "tool":
|
||||
if not pending_tool_call_ids:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
if call_id == pending_confirm_changes_id:
|
||||
pending_confirm_changes_id = None
|
||||
break
|
||||
if keep:
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove duplicate messages while preserving order."""
|
||||
seen_keys: dict[Any, int] = {}
|
||||
unique_messages: list[ChatMessage] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role_value = get_role_value(msg)
|
||||
|
||||
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
if key in seen_keys:
|
||||
existing_idx = seen_keys[key]
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
if (not existing_result or existing_result == "") and new_result:
|
||||
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
|
||||
unique_messages[existing_idx] = msg
|
||||
else:
|
||||
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
elif (
|
||||
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
|
||||
):
|
||||
tool_call_ids = tuple(
|
||||
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
return unique_messages
|
||||
|
||||
|
||||
def normalize_agui_input_messages(
|
||||
messages: list[dict[str, Any]],
|
||||
) -> tuple[list[ChatMessage], list[dict[str, Any]]]:
|
||||
"""Normalize raw AG-UI messages into provider and snapshot formats."""
|
||||
provider_messages = agui_messages_to_agent_framework(messages)
|
||||
provider_messages = _sanitize_tool_history(provider_messages)
|
||||
provider_messages = _deduplicate_messages(provider_messages)
|
||||
snapshot_messages = agui_messages_to_snapshot_format(messages)
|
||||
return provider_messages, snapshot_messages
|
||||
|
||||
|
||||
def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]:
|
||||
@@ -38,11 +247,108 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
Returns:
|
||||
List of Agent Framework ChatMessage objects
|
||||
"""
|
||||
|
||||
def _update_tool_call_arguments(
|
||||
raw_messages: list[dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
modified_args: dict[str, Any],
|
||||
) -> None:
|
||||
for raw_msg in raw_messages:
|
||||
tool_calls = raw_msg.get("tool_calls") or raw_msg.get("toolCalls")
|
||||
if not isinstance(tool_calls, list):
|
||||
continue
|
||||
tool_calls_list = cast(list[Any], tool_calls)
|
||||
for tool_call in tool_calls_list:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_call_dict = cast(dict[str, Any], tool_call)
|
||||
if str(tool_call_dict.get("id", "")) != tool_call_id:
|
||||
continue
|
||||
function_payload = tool_call_dict.get("function")
|
||||
if not isinstance(function_payload, dict):
|
||||
return
|
||||
function_payload_dict = cast(dict[str, Any], function_payload)
|
||||
existing_args = function_payload_dict.get("arguments")
|
||||
if isinstance(existing_args, str):
|
||||
function_payload_dict["arguments"] = json.dumps(modified_args)
|
||||
else:
|
||||
function_payload_dict["arguments"] = modified_args
|
||||
return
|
||||
|
||||
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
|
||||
for prev_msg in result:
|
||||
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
|
||||
if role_val != "assistant":
|
||||
continue
|
||||
for content in prev_msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
if content.call_id == call_id and content.name != "confirm_changes":
|
||||
return content
|
||||
return None
|
||||
|
||||
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
|
||||
return safe_json_parse(arguments)
|
||||
|
||||
def _resolve_approval_call_id(tool_call_id: str, parsed_payload: dict[str, Any] | None) -> str | None:
|
||||
if parsed_payload:
|
||||
explicit_call_id = parsed_payload.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
for prev_msg in result:
|
||||
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
|
||||
if role_val != "assistant":
|
||||
continue
|
||||
direct_call = None
|
||||
confirm_call = None
|
||||
sibling_calls: list[FunctionCallContent] = []
|
||||
for content in prev_msg.contents or []:
|
||||
if not isinstance(content, FunctionCallContent):
|
||||
continue
|
||||
if content.call_id == tool_call_id:
|
||||
direct_call = content
|
||||
if content.name == "confirm_changes" and content.call_id == tool_call_id:
|
||||
confirm_call = content
|
||||
elif content.name != "confirm_changes":
|
||||
sibling_calls.append(content)
|
||||
|
||||
if direct_call:
|
||||
direct_args = direct_call.parse_arguments() or {}
|
||||
if isinstance(direct_args, dict):
|
||||
explicit_call_id = direct_args.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
if not confirm_call:
|
||||
continue
|
||||
|
||||
confirm_args = confirm_call.parse_arguments() or {}
|
||||
if isinstance(confirm_args, dict):
|
||||
explicit_call_id = confirm_args.get("function_call_id")
|
||||
if explicit_call_id:
|
||||
return str(explicit_call_id)
|
||||
|
||||
if len(sibling_calls) == 1 and sibling_calls[0].call_id:
|
||||
return str(sibling_calls[0].call_id)
|
||||
|
||||
return None
|
||||
|
||||
def _filter_modified_args(
|
||||
modified_args: dict[str, Any],
|
||||
original_args: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
if not modified_args:
|
||||
return {}
|
||||
if not isinstance(original_args, dict) or not original_args:
|
||||
return {}
|
||||
allowed_keys = set(original_args.keys())
|
||||
return {key: value for key, value in modified_args.items() if key in allowed_keys}
|
||||
|
||||
result: list[ChatMessage] = []
|
||||
for msg in messages:
|
||||
# Handle standard tool result messages early (role="tool") to preserve provider invariants
|
||||
# This path maps AG‑UI tool messages to FunctionResultContent with the correct tool_call_id
|
||||
role_str = msg.get("role", "user")
|
||||
role_str = normalize_agui_role(msg.get("role", "user"))
|
||||
if role_str == "tool":
|
||||
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
|
||||
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
|
||||
@@ -59,29 +365,153 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
result_content = msg.get("result", "")
|
||||
|
||||
# Distinguish approval payloads from actual tool results
|
||||
is_approval = False
|
||||
parsed: dict[str, Any] | None = None
|
||||
if isinstance(result_content, str) and result_content:
|
||||
try:
|
||||
parsed = json.loads(result_content)
|
||||
is_approval = isinstance(parsed, dict) and "accepted" in parsed
|
||||
parsed_candidate = json.loads(result_content)
|
||||
except Exception:
|
||||
is_approval = False
|
||||
parsed_candidate = None
|
||||
if isinstance(parsed_candidate, dict):
|
||||
parsed = cast(dict[str, Any], parsed_candidate)
|
||||
elif isinstance(result_content, dict):
|
||||
parsed = cast(dict[str, Any], result_content)
|
||||
|
||||
is_approval = parsed is not None and "accepted" in parsed
|
||||
|
||||
if is_approval:
|
||||
# Approval responses should be treated as user messages to trigger human-in-the-loop flow
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=str(result_content))],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
# Look for the matching function call in previous messages to create
|
||||
# a proper FunctionApprovalResponseContent. This enables the agent framework
|
||||
# to execute the approved tool (fix for GitHub issue #3034).
|
||||
accepted = parsed.get("accepted", False) if parsed is not None else False
|
||||
approval_payload_text = result_content if isinstance(result_content, str) else json.dumps(parsed)
|
||||
|
||||
# Log the full approval payload to debug modified arguments
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Approval payload received: {parsed}")
|
||||
|
||||
approval_call_id = tool_call_id
|
||||
resolved_call_id = _resolve_approval_call_id(tool_call_id, parsed)
|
||||
if resolved_call_id:
|
||||
approval_call_id = resolved_call_id
|
||||
matching_func_call = _find_matching_func_call(approval_call_id)
|
||||
|
||||
if matching_func_call:
|
||||
# Remove any existing tool result for this call_id since the framework
|
||||
# will re-execute the tool after approval. Keeping old results causes
|
||||
# OpenAI API errors ("tool message must follow assistant with tool_calls").
|
||||
result = [
|
||||
m
|
||||
for m in result
|
||||
if not (
|
||||
(m.role.value if hasattr(m.role, "value") else str(m.role)) == "tool"
|
||||
and any(
|
||||
isinstance(c, FunctionResultContent) and c.call_id == approval_call_id
|
||||
for c in (m.contents or [])
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
# Check if the approval payload contains modified arguments
|
||||
# The UI sends back the modified state (e.g., deselected steps) in the approval payload
|
||||
modified_args = {k: v for k, v in parsed.items() if k != "accepted"} if parsed else {}
|
||||
original_args = matching_func_call.parse_arguments()
|
||||
filtered_args = _filter_modified_args(modified_args, original_args)
|
||||
state_args: dict[str, Any] | None = None
|
||||
if filtered_args:
|
||||
original_args = original_args or {}
|
||||
merged_args: dict[str, Any]
|
||||
if isinstance(original_args, dict) and original_args:
|
||||
merged_args = {**original_args, **filtered_args}
|
||||
else:
|
||||
merged_args = dict(filtered_args)
|
||||
|
||||
if isinstance(filtered_args.get("steps"), list):
|
||||
original_steps = original_args.get("steps") if isinstance(original_args, dict) else None
|
||||
if isinstance(original_steps, list):
|
||||
approved_steps_list = list(filtered_args.get("steps") or [])
|
||||
approved_by_description: dict[str, dict[str, Any]] = {}
|
||||
for step_item in approved_steps_list:
|
||||
if isinstance(step_item, dict):
|
||||
step_item_dict = cast(dict[str, Any], step_item)
|
||||
desc = step_item_dict.get("description")
|
||||
if desc:
|
||||
approved_by_description[str(desc)] = step_item_dict
|
||||
merged_steps: list[Any] = []
|
||||
original_steps_list = cast(list[Any], original_steps)
|
||||
for orig_step in original_steps_list:
|
||||
if not isinstance(orig_step, dict):
|
||||
merged_steps.append(orig_step)
|
||||
continue
|
||||
orig_step_dict = cast(dict[str, Any], orig_step)
|
||||
description = str(orig_step_dict.get("description", ""))
|
||||
approved_step = approved_by_description.get(description)
|
||||
status: str = (
|
||||
str(approved_step.get("status"))
|
||||
if approved_step is not None and approved_step.get("status")
|
||||
else "disabled"
|
||||
)
|
||||
updated_step: dict[str, Any] = orig_step_dict.copy()
|
||||
updated_step["status"] = status
|
||||
merged_steps.append(updated_step)
|
||||
merged_args["steps"] = merged_steps
|
||||
state_args = merged_args
|
||||
|
||||
# Keep the original tool call and AG-UI snapshot in sync with approved args.
|
||||
updated_args = (
|
||||
json.dumps(merged_args) if isinstance(matching_func_call.arguments, str) else merged_args
|
||||
)
|
||||
matching_func_call.arguments = updated_args
|
||||
_update_tool_call_arguments(messages, str(approval_call_id), merged_args)
|
||||
# Create a new FunctionCallContent with the modified arguments
|
||||
func_call_for_approval = FunctionCallContent(
|
||||
call_id=matching_func_call.call_id,
|
||||
name=matching_func_call.name,
|
||||
arguments=json.dumps(filtered_args),
|
||||
)
|
||||
logger.info(f"Using modified arguments from approval: {filtered_args}")
|
||||
else:
|
||||
# No modified arguments - use the original function call
|
||||
func_call_for_approval = matching_func_call
|
||||
|
||||
# Create FunctionApprovalResponseContent for the agent framework
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approved=accepted,
|
||||
id=str(approval_call_id),
|
||||
function_call=func_call_for_approval,
|
||||
additional_properties={"ag_ui_state_args": state_args} if state_args else None,
|
||||
)
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[approval_response],
|
||||
)
|
||||
else:
|
||||
# No matching function call found - this is likely a confirm_changes approval
|
||||
# Keep the old behavior for backwards compatibility
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=approval_payload_text)],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
# Cast result_content to acceptable type for FunctionResultContent
|
||||
func_result: str | dict[str, Any] | list[Any]
|
||||
if isinstance(result_content, str):
|
||||
func_result = result_content
|
||||
elif isinstance(result_content, dict):
|
||||
func_result = cast(dict[str, Any], result_content)
|
||||
elif isinstance(result_content, list):
|
||||
func_result = cast(list[Any], result_content)
|
||||
else:
|
||||
func_result = str(result_content)
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
@@ -142,7 +572,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
|
||||
# No special handling required for assistant/plain messages here
|
||||
|
||||
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
role = AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
|
||||
|
||||
# Check if this message contains function approvals
|
||||
if "function_approvals" in msg and msg["function_approvals"]:
|
||||
@@ -198,6 +628,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
if isinstance(msg, dict):
|
||||
# Always work on a copy to avoid mutating input
|
||||
normalized_msg = msg.copy()
|
||||
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
|
||||
# Ensure ID exists
|
||||
if "id" not in normalized_msg:
|
||||
normalized_msg["id"] = generate_event_id()
|
||||
@@ -214,7 +645,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
continue
|
||||
|
||||
# Convert ChatMessage to AG-UI format
|
||||
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
|
||||
role = FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
|
||||
|
||||
content_text = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
@@ -303,22 +734,44 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
|
||||
content = normalized_msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Convert content array format to simple string
|
||||
text_parts = []
|
||||
for item in content:
|
||||
text_parts: list[str] = []
|
||||
content_list = cast(list[Any], content)
|
||||
for item in content_list:
|
||||
if isinstance(item, dict):
|
||||
item_dict = cast(dict[str, Any], item)
|
||||
# Convert 'input_text' to 'text' type
|
||||
if item.get("type") == "input_text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
elif item.get("type") == "text":
|
||||
text_parts.append(item.get("text", ""))
|
||||
if item_dict.get("type") == "input_text":
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
elif item_dict.get("type") == "text":
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
else:
|
||||
# Other types - just extract text field if present
|
||||
text_parts.append(item.get("text", ""))
|
||||
text_parts.append(str(item_dict.get("text", "")))
|
||||
normalized_msg["content"] = "".join(text_parts)
|
||||
elif content is None:
|
||||
normalized_msg["content"] = ""
|
||||
|
||||
tool_calls = normalized_msg.get("tool_calls") or normalized_msg.get("toolCalls")
|
||||
if isinstance(tool_calls, list):
|
||||
tool_calls_list = cast(list[Any], tool_calls)
|
||||
for tool_call in tool_calls_list:
|
||||
if not isinstance(tool_call, dict):
|
||||
continue
|
||||
tool_call_dict = cast(dict[str, Any], tool_call)
|
||||
function_payload = tool_call_dict.get("function")
|
||||
if not isinstance(function_payload, dict):
|
||||
continue
|
||||
function_payload_dict = cast(dict[str, Any], function_payload)
|
||||
if "arguments" not in function_payload_dict:
|
||||
continue
|
||||
arguments = function_payload_dict.get("arguments")
|
||||
if arguments is None:
|
||||
function_payload_dict["arguments"] = ""
|
||||
elif not isinstance(arguments, str):
|
||||
function_payload_dict["arguments"] = json.dumps(arguments)
|
||||
|
||||
# Normalize tool_call_id to toolCallId for tool messages
|
||||
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
|
||||
if normalized_msg.get("role") == "tool":
|
||||
if "tool_call_id" in normalized_msg:
|
||||
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Helper functions for orchestration logic."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from .._utils import get_role_value, safe_json_parse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._events import AgentFrameworkEventBridge
|
||||
from ._state_manager import StateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
|
||||
"""Get IDs of tool calls without corresponding results.
|
||||
|
||||
Args:
|
||||
messages: List of messages to scan
|
||||
|
||||
Returns:
|
||||
Set of pending tool call IDs
|
||||
"""
|
||||
pending_ids: set[str] = set()
|
||||
resolved_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.call_id:
|
||||
pending_ids.add(str(content.call_id))
|
||||
elif isinstance(content, FunctionResultContent) and content.call_id:
|
||||
resolved_ids.add(str(content.call_id))
|
||||
return pending_ids - resolved_ids
|
||||
|
||||
|
||||
def is_state_context_message(message: ChatMessage) -> bool:
|
||||
"""Check if a message is a state context system message.
|
||||
|
||||
Args:
|
||||
message: Message to check
|
||||
|
||||
Returns:
|
||||
True if this is a state context message
|
||||
"""
|
||||
if get_role_value(message) != "system":
|
||||
return False
|
||||
for content in message.contents:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ensure_tool_call_entry(
|
||||
tool_call_id: str,
|
||||
tool_calls_by_id: dict[str, dict[str, Any]],
|
||||
pending_tool_calls: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Get or create a tool call entry in the tracking dicts.
|
||||
|
||||
Args:
|
||||
tool_call_id: The tool call ID
|
||||
tool_calls_by_id: Dict mapping IDs to tool call entries
|
||||
pending_tool_calls: List of pending tool calls
|
||||
|
||||
Returns:
|
||||
The tool call entry dict
|
||||
"""
|
||||
entry = tool_calls_by_id.get(tool_call_id)
|
||||
if entry is None:
|
||||
entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "",
|
||||
"arguments": "",
|
||||
},
|
||||
}
|
||||
tool_calls_by_id[tool_call_id] = entry
|
||||
pending_tool_calls.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def tool_name_for_call_id(
|
||||
tool_calls_by_id: dict[str, dict[str, Any]],
|
||||
tool_call_id: str,
|
||||
) -> str | None:
|
||||
"""Get the tool name for a given call ID.
|
||||
|
||||
Args:
|
||||
tool_calls_by_id: Dict mapping IDs to tool call entries
|
||||
tool_call_id: The tool call ID to look up
|
||||
|
||||
Returns:
|
||||
Tool name or None if not found
|
||||
"""
|
||||
entry = tool_calls_by_id.get(tool_call_id)
|
||||
if not entry:
|
||||
return None
|
||||
function = entry.get("function")
|
||||
if not isinstance(function, dict):
|
||||
return None
|
||||
name = function.get("name")
|
||||
return str(name) if name else None
|
||||
|
||||
|
||||
def tool_calls_match_state(
|
||||
provider_messages: list[ChatMessage],
|
||||
state_manager: "StateManager",
|
||||
) -> bool:
|
||||
"""Check if tool calls in messages match current state.
|
||||
|
||||
Args:
|
||||
provider_messages: Messages to check
|
||||
state_manager: State manager with config and current state
|
||||
|
||||
Returns:
|
||||
True if tool calls match state configuration
|
||||
"""
|
||||
if not state_manager.predict_state_config or not state_manager.current_state:
|
||||
return False
|
||||
|
||||
for state_key, config in state_manager.predict_state_config.items():
|
||||
tool_name = config["tool"]
|
||||
tool_arg_name = config["tool_argument"]
|
||||
tool_args: dict[str, Any] | None = None
|
||||
|
||||
for msg in reversed(provider_messages):
|
||||
if get_role_value(msg) != "assistant":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.name == tool_name:
|
||||
tool_args = safe_json_parse(content.arguments)
|
||||
break
|
||||
if tool_args is not None:
|
||||
break
|
||||
|
||||
if not tool_args:
|
||||
return False
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = tool_args
|
||||
elif tool_arg_name in tool_args:
|
||||
state_value = tool_args[tool_arg_name]
|
||||
else:
|
||||
return False
|
||||
|
||||
if state_manager.current_state.get(state_key) != state_value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def schema_has_steps(schema: Any) -> bool:
|
||||
"""Check if a schema has a steps array property.
|
||||
|
||||
Args:
|
||||
schema: JSON schema to check
|
||||
|
||||
Returns:
|
||||
True if schema has steps array
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return False
|
||||
steps_schema = properties.get("steps")
|
||||
if not isinstance(steps_schema, dict):
|
||||
return False
|
||||
return steps_schema.get("type") == "array"
|
||||
|
||||
|
||||
def select_approval_tool_name(client_tools: list[Any] | None) -> str | None:
|
||||
"""Select appropriate approval tool from client tools.
|
||||
|
||||
Args:
|
||||
client_tools: List of client tool definitions
|
||||
|
||||
Returns:
|
||||
Name of approval tool, or None if not found
|
||||
"""
|
||||
if not client_tools:
|
||||
return None
|
||||
for tool in client_tools:
|
||||
tool_name = getattr(tool, "name", None)
|
||||
if not tool_name:
|
||||
continue
|
||||
params_fn = getattr(tool, "parameters", None)
|
||||
if not callable(params_fn):
|
||||
continue
|
||||
schema = params_fn()
|
||||
if schema_has_steps(schema):
|
||||
return str(tool_name)
|
||||
return None
|
||||
|
||||
|
||||
def select_messages_to_run(
|
||||
provider_messages: list[ChatMessage],
|
||||
state_manager: "StateManager",
|
||||
) -> list[ChatMessage]:
|
||||
"""Select and prepare messages for agent execution.
|
||||
|
||||
Injects state context message when appropriate.
|
||||
|
||||
Args:
|
||||
provider_messages: Original messages from client
|
||||
state_manager: State manager instance
|
||||
|
||||
Returns:
|
||||
Messages ready for agent execution
|
||||
"""
|
||||
if not provider_messages:
|
||||
return []
|
||||
|
||||
is_new_user_turn = get_role_value(provider_messages[-1]) == "user"
|
||||
conversation_has_tool_calls = tool_calls_match_state(provider_messages, state_manager)
|
||||
state_context_msg = state_manager.state_context_message(
|
||||
is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls
|
||||
)
|
||||
if not state_context_msg:
|
||||
return list(provider_messages)
|
||||
|
||||
messages_to_run = [msg for msg in provider_messages if not is_state_context_message(msg)]
|
||||
if pending_tool_call_ids(messages_to_run):
|
||||
return messages_to_run
|
||||
|
||||
insert_index = len(messages_to_run) - 1 if is_new_user_turn else len(messages_to_run)
|
||||
if insert_index < 0:
|
||||
insert_index = 0
|
||||
messages_to_run.insert(insert_index, state_context_msg)
|
||||
return messages_to_run
|
||||
|
||||
|
||||
def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Build metadata dict with truncated string values.
|
||||
|
||||
Args:
|
||||
thread_metadata: Raw metadata dict
|
||||
|
||||
Returns:
|
||||
Metadata with string values truncated to 512 chars
|
||||
"""
|
||||
if not thread_metadata:
|
||||
return {}
|
||||
safe_metadata: dict[str, Any] = {}
|
||||
for key, value in thread_metadata.items():
|
||||
value_str = value if isinstance(value, str) else json.dumps(value)
|
||||
if len(value_str) > 512:
|
||||
value_str = value_str[:512]
|
||||
safe_metadata[key] = value_str
|
||||
return safe_metadata
|
||||
|
||||
|
||||
def collect_approved_state_snapshots(
|
||||
provider_messages: list[ChatMessage],
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
current_state: dict[str, Any],
|
||||
event_bridge: "AgentFrameworkEventBridge",
|
||||
) -> list[StateSnapshotEvent]:
|
||||
"""Collect state snapshots from approved function calls.
|
||||
|
||||
Args:
|
||||
provider_messages: Messages containing approvals
|
||||
predict_state_config: Predictive state configuration
|
||||
current_state: Current state dict (will be mutated)
|
||||
event_bridge: Event bridge for creating events
|
||||
|
||||
Returns:
|
||||
List of state snapshot events
|
||||
"""
|
||||
if not predict_state_config:
|
||||
return []
|
||||
|
||||
events: list[StateSnapshotEvent] = []
|
||||
for msg in provider_messages:
|
||||
if get_role_value(msg) != "user":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if not content.function_call or not content.approved:
|
||||
continue
|
||||
parsed_args = content.function_call.parse_arguments()
|
||||
state_args = None
|
||||
if content.additional_properties:
|
||||
state_args = content.additional_properties.get("ag_ui_state_args")
|
||||
if not isinstance(state_args, dict):
|
||||
state_args = parsed_args
|
||||
if not state_args:
|
||||
continue
|
||||
for state_key, config in predict_state_config.items():
|
||||
if config["tool"] != content.function_call.name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
if tool_arg_name == "*":
|
||||
state_value = state_args
|
||||
elif isinstance(state_args, dict) and tool_arg_name in state_args:
|
||||
state_value = state_args[tool_arg_name]
|
||||
else:
|
||||
continue
|
||||
current_state[state_key] = state_value
|
||||
event_bridge.current_state[state_key] = state_value
|
||||
logger.info(
|
||||
f"Emitting StateSnapshotEvent for approved state key '{state_key}' "
|
||||
f"with {len(state_value) if isinstance(state_value, list) else 'N/A'} items"
|
||||
)
|
||||
events.append(StateSnapshotEvent(snapshot=current_state))
|
||||
break
|
||||
return events
|
||||
|
||||
|
||||
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
|
||||
"""Get the latest approval response from messages.
|
||||
|
||||
Args:
|
||||
messages: Messages to search
|
||||
|
||||
Returns:
|
||||
Latest approval response or None
|
||||
"""
|
||||
if not messages:
|
||||
return None
|
||||
last_message = messages[-1]
|
||||
for content in last_message.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
|
||||
"""Extract steps from an approval response.
|
||||
|
||||
Args:
|
||||
approval: Approval response content
|
||||
|
||||
Returns:
|
||||
List of steps, or empty list if none
|
||||
"""
|
||||
state_args: Any | None = None
|
||||
if approval.additional_properties:
|
||||
state_args = approval.additional_properties.get("ag_ui_state_args")
|
||||
if isinstance(state_args, dict):
|
||||
steps = state_args.get("steps")
|
||||
if isinstance(steps, list):
|
||||
return steps
|
||||
|
||||
if approval.function_call:
|
||||
parsed_args = approval.function_call.parse_arguments()
|
||||
if isinstance(parsed_args, dict):
|
||||
steps = parsed_args.get("steps")
|
||||
if isinstance(steps, list):
|
||||
return steps
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def is_step_based_approval(
|
||||
approval: FunctionApprovalResponseContent,
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
) -> bool:
|
||||
"""Check if an approval is step-based.
|
||||
|
||||
Args:
|
||||
approval: Approval response to check
|
||||
predict_state_config: Predictive state configuration
|
||||
|
||||
Returns:
|
||||
True if this is a step-based approval
|
||||
"""
|
||||
steps = approval_steps(approval)
|
||||
if steps:
|
||||
return True
|
||||
if not approval.function_call:
|
||||
return False
|
||||
if not predict_state_config:
|
||||
return False
|
||||
tool_name = approval.function_call.name
|
||||
for config in predict_state_config.values():
|
||||
if config.get("tool") == tool_name and config.get("tool_argument") == "steps":
|
||||
return True
|
||||
return False
|
||||
@@ -1,176 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Message hygiene utilities for orchestrators."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
|
||||
sanitized: list[ChatMessage] = []
|
||||
pending_tool_call_ids: set[str] | None = None
|
||||
pending_confirm_changes_id: str | None = None
|
||||
|
||||
for msg in messages:
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
if role_value == "assistant":
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
}
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = tool_ids if tool_ids else None
|
||||
pending_confirm_changes_id = (
|
||||
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
|
||||
)
|
||||
continue
|
||||
|
||||
if role_value == "user":
|
||||
if pending_confirm_changes_id:
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
if "accepted" in parsed:
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
)
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
if pending_tool_call_ids:
|
||||
pending_tool_call_ids.discard(pending_confirm_changes_id)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
except (json.JSONDecodeError, KeyError) as exc:
|
||||
logger.debug("Could not parse user message as confirm_changes response: %s", type(exc).__name__)
|
||||
|
||||
if pending_tool_call_ids:
|
||||
logger.info(
|
||||
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results"
|
||||
)
|
||||
for pending_call_id in pending_tool_call_ids:
|
||||
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
call_id=pending_call_id,
|
||||
result="Tool execution skipped - user provided follow-up message",
|
||||
)
|
||||
],
|
||||
)
|
||||
sanitized.append(synthetic_result)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_confirm_changes_id = None
|
||||
continue
|
||||
|
||||
if role_value == "tool":
|
||||
if not pending_tool_call_ids:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
if call_id == pending_confirm_changes_id:
|
||||
pending_confirm_changes_id = None
|
||||
break
|
||||
if keep:
|
||||
sanitized.append(msg)
|
||||
continue
|
||||
|
||||
sanitized.append(msg)
|
||||
pending_tool_call_ids = None
|
||||
pending_confirm_changes_id = None
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
"""Remove duplicate messages while preserving order."""
|
||||
seen_keys: dict[Any, int] = {}
|
||||
unique_messages: list[ChatMessage] = []
|
||||
|
||||
for idx, msg in enumerate(messages):
|
||||
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
|
||||
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
if key in seen_keys:
|
||||
existing_idx = seen_keys[key]
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
if (not existing_result or existing_result == "") and new_result:
|
||||
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
|
||||
unique_messages[existing_idx] = msg
|
||||
else:
|
||||
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
elif (
|
||||
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
|
||||
):
|
||||
tool_call_ids = tuple(
|
||||
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
else:
|
||||
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
|
||||
key = (role_value, hash(content_str))
|
||||
|
||||
if key in seen_keys:
|
||||
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
|
||||
continue
|
||||
|
||||
seen_keys[key] = len(unique_messages)
|
||||
unique_messages.append(msg)
|
||||
|
||||
return unique_messages
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Predictive state handling utilities."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import StateDeltaEvent
|
||||
|
||||
from .._utils import safe_json_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PredictiveStateHandler:
|
||||
"""Handles predictive state updates from streaming tool calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
predict_state_config: dict[str, dict[str, str]] | None = None,
|
||||
current_state: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Initialize the handler.
|
||||
|
||||
Args:
|
||||
predict_state_config: Configuration mapping state keys to tool/argument pairs
|
||||
current_state: Reference to current state dict
|
||||
"""
|
||||
self.predict_state_config = predict_state_config or {}
|
||||
self.current_state = current_state or {}
|
||||
self.streaming_tool_args: str = ""
|
||||
self.last_emitted_state: dict[str, Any] = {}
|
||||
self.state_delta_count: int = 0
|
||||
self.pending_state_updates: dict[str, Any] = {}
|
||||
|
||||
def reset_streaming(self) -> None:
|
||||
"""Reset streaming state for a new tool call."""
|
||||
self.streaming_tool_args = ""
|
||||
self.state_delta_count = 0
|
||||
|
||||
def extract_state_value(
|
||||
self,
|
||||
tool_name: str,
|
||||
args: dict[str, Any] | str | None,
|
||||
) -> tuple[str, Any] | None:
|
||||
"""Extract state value from tool arguments based on config.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool being called
|
||||
args: Tool arguments (dict or JSON string)
|
||||
|
||||
Returns:
|
||||
Tuple of (state_key, state_value) or None if no match
|
||||
"""
|
||||
if not self.predict_state_config:
|
||||
return None
|
||||
|
||||
parsed_args = safe_json_parse(args) if isinstance(args, str) else args
|
||||
if not parsed_args:
|
||||
return None
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
if tool_arg_name == "*":
|
||||
return (state_key, parsed_args)
|
||||
if tool_arg_name in parsed_args:
|
||||
return (state_key, parsed_args[tool_arg_name])
|
||||
|
||||
return None
|
||||
|
||||
def is_predictive_tool(self, tool_name: str | None) -> bool:
|
||||
"""Check if a tool is configured for predictive state.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to check
|
||||
|
||||
Returns:
|
||||
True if tool is in predictive state config
|
||||
"""
|
||||
if not tool_name or not self.predict_state_config:
|
||||
return False
|
||||
for config in self.predict_state_config.values():
|
||||
if config["tool"] == tool_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
def emit_streaming_deltas(
|
||||
self,
|
||||
tool_name: str | None,
|
||||
argument_chunk: str,
|
||||
) -> list[StateDeltaEvent]:
|
||||
"""Process streaming argument chunk and emit state deltas.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
argument_chunk: New chunk of JSON arguments
|
||||
|
||||
Returns:
|
||||
List of state delta events to emit
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
if not tool_name or not self.predict_state_config:
|
||||
return events
|
||||
|
||||
self.streaming_tool_args += argument_chunk
|
||||
logger.debug(
|
||||
"Predictive state: accumulated %s chars for tool '%s'",
|
||||
len(self.streaming_tool_args),
|
||||
tool_name,
|
||||
)
|
||||
|
||||
# Try to parse complete JSON first
|
||||
parsed_args = None
|
||||
try:
|
||||
parsed_args = json.loads(self.streaming_tool_args)
|
||||
except json.JSONDecodeError:
|
||||
# Fall back to regex matching for partial JSON
|
||||
events.extend(self._emit_partial_deltas(tool_name))
|
||||
|
||||
if parsed_args:
|
||||
events.extend(self._emit_complete_deltas(tool_name, parsed_args))
|
||||
|
||||
return events
|
||||
|
||||
def _emit_partial_deltas(self, tool_name: str) -> list[StateDeltaEvent]:
|
||||
"""Emit deltas from partial JSON using regex matching.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
|
||||
Returns:
|
||||
List of state delta events
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
|
||||
match = re.search(pattern, self.streaming_tool_args)
|
||||
|
||||
if match:
|
||||
partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value:
|
||||
event = self._create_delta_event(state_key, partial_value)
|
||||
events.append(event)
|
||||
self.last_emitted_state[state_key] = partial_value
|
||||
self.pending_state_updates[state_key] = partial_value
|
||||
|
||||
return events
|
||||
|
||||
def _emit_complete_deltas(
|
||||
self,
|
||||
tool_name: str,
|
||||
parsed_args: dict[str, Any],
|
||||
) -> list[StateDeltaEvent]:
|
||||
"""Emit deltas from complete parsed JSON.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the current tool
|
||||
parsed_args: Fully parsed arguments dict
|
||||
|
||||
Returns:
|
||||
List of state delta events
|
||||
"""
|
||||
events: list[StateDeltaEvent] = []
|
||||
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != tool_name:
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
|
||||
if tool_arg_name == "*":
|
||||
state_value = parsed_args
|
||||
elif tool_arg_name in parsed_args:
|
||||
state_value = parsed_args[tool_arg_name]
|
||||
else:
|
||||
continue
|
||||
|
||||
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
|
||||
event = self._create_delta_event(state_key, state_value)
|
||||
events.append(event)
|
||||
self.last_emitted_state[state_key] = state_value
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
|
||||
return events
|
||||
|
||||
def _create_delta_event(self, state_key: str, value: Any) -> StateDeltaEvent:
|
||||
"""Create a state delta event with logging.
|
||||
|
||||
Args:
|
||||
state_key: The state key being updated
|
||||
value: The new value
|
||||
|
||||
Returns:
|
||||
StateDeltaEvent instance
|
||||
"""
|
||||
self.state_delta_count += 1
|
||||
if self.state_delta_count % 10 == 1:
|
||||
logger.info(
|
||||
"StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s",
|
||||
self.state_delta_count,
|
||||
state_key,
|
||||
state_key,
|
||||
len(str(value)),
|
||||
)
|
||||
elif self.state_delta_count % 100 == 0:
|
||||
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
|
||||
|
||||
return StateDeltaEvent(
|
||||
delta=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": f"/{state_key}",
|
||||
"value": value,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
def apply_pending_updates(self) -> None:
|
||||
"""Apply pending updates to current state and clear them."""
|
||||
for key, value in self.pending_state_updates.items():
|
||||
self.current_state[key] = value
|
||||
self.pending_state_updates.clear()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user