mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad4b732741 | ||
|
|
b4a71f00a3 | ||
|
|
082f39e77e | ||
|
|
6f1ab66795 | ||
|
|
d402d92a47 | ||
|
|
d55dd5f253 | ||
|
|
88e0ee1a2c | ||
|
|
aa6579f38c | ||
|
|
41cc34421f | ||
|
|
eac8baac09 | ||
|
|
77236bf0ec | ||
|
|
6d7690e485 | ||
|
|
6b5437e4ec | ||
|
|
db8a59bd3d | ||
|
|
83e6229c11 | ||
|
|
73761aa4a3 | ||
|
|
742937194a | ||
|
|
74401266e6 | ||
|
|
f8c84d4ee6 | ||
|
|
3ec881509c | ||
|
|
8ee379d344 | ||
|
|
2a43caefaa | ||
|
|
f54248b79f | ||
|
|
0f29637b86 | ||
|
|
e0b9be7e08 | ||
|
|
83e8965c8e | ||
|
|
3c1be2a713 | ||
|
|
467d3a60ed | ||
|
|
3243652df6 | ||
|
|
915df3b404 | ||
|
|
f87e55ba33 | ||
|
|
9bfa1a913c |
@@ -12,7 +12,7 @@ runs:
|
||||
docker rm -f dts-emulator
|
||||
fi
|
||||
echo "Starting Durable Task Scheduler Emulator"
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
|
||||
echo "Waiting for Durable Task Scheduler Emulator to be ready"
|
||||
timeout 30 bash -c 'until curl --silent http://localhost:8080/healthz; do sleep 1; done'
|
||||
echo "Durable Task Scheduler Emulator is ready"
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v5.0.1
|
||||
uses: actions/setup-dotnet@v5.1.0
|
||||
with:
|
||||
global-json-file: ${{ github.workspace }}/dotnet/global.json
|
||||
- name: Build dotnet solutions
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
timeout: 3600
|
||||
interval: 30
|
||||
ignored: CodeQL
|
||||
ignored: CodeQL,CodeQL analysis (csharp)
|
||||
|
||||
@@ -34,9 +34,16 @@ jobs:
|
||||
# because the workflow_run event does not have access to the PR number
|
||||
# The PR number is needed to post the comment on the PR
|
||||
run: |
|
||||
PR_NUMBER=$(cat pr_number)
|
||||
echo "PR number: $PR_NUMBER"
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||||
if [ ! -s pr_number ]; then
|
||||
echo "PR number file 'pr_number' is missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
|
||||
if [ -z "$PR_NUMBER" ]; then
|
||||
echo "PR number file 'pr_number' does not contain a valid PR number"
|
||||
exit 1
|
||||
fi
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@v1.2.0
|
||||
|
||||
@@ -33,18 +33,18 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.1" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -61,9 +61,9 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.2.0-preview.1.26063.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||
@@ -71,11 +71,11 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
|
||||
@@ -35,6 +35,18 @@
|
||||
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
|
||||
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/DurableAgents/">
|
||||
<File Path="samples/DurableAgents/ConsoleApps/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/DurableAgents/ConsoleApps/">
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
|
||||
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
</Folder>
|
||||
@@ -81,6 +93,7 @@
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj" />
|
||||
<Project Path="samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Agent_Step20_AdditionalAIContext.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/DeclarativeAgents/">
|
||||
<Project Path="samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260108.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260108.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.260108.1</GitTag>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260121.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260121.1</PackageVersion>
|
||||
<GitTag>1.0.0-preview.260121.1</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SingleAgent</AssemblyName>
|
||||
<RootNamespace>SingleAgent</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
|
||||
const string JokerName = "Joker";
|
||||
const string JokerInstructions = "You are good at telling jokes.";
|
||||
|
||||
AIAgent agent = client.GetChatClient(deploymentName).AsAIAgent(JokerInstructions, JokerName);
|
||||
|
||||
// Configure the console app to host the AI agent.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1)),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the agent proxy from services
|
||||
IServiceProvider services = host.Services;
|
||||
AIAgent agentProxy = services.GetRequiredKeyedService<AIAgent>(JokerName);
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Single Agent Console Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter a message for the Joker agent (or 'exit' to quit):");
|
||||
Console.WriteLine();
|
||||
|
||||
// Create a thread for the conversation
|
||||
AgentThread thread = await agentProxy.GetNewThreadAsync();
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Read input from stdin
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("You: ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Run the agent
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("Joker: ");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
AgentResponse agentResponse = await agentProxy.RunAsync(
|
||||
message: input,
|
||||
thread: thread,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
Console.WriteLine(agentResponse.Text);
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
@@ -0,0 +1,56 @@
|
||||
# Single Agent Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a simple console app that hosts a single AI agent and provides interactive conversation via stdin/stdout.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
|
||||
- Registering durable agents with the console app and running them interactively.
|
||||
- Conversation management (via threads) 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/01_SingleAgent
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for input. You can interact with the Joker agent:
|
||||
|
||||
```text
|
||||
=== Single Agent Console Sample ===
|
||||
Enter a message for the Joker agent (or 'exit' to quit):
|
||||
|
||||
You: Tell me a joke about a pirate.
|
||||
Joker: Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
|
||||
|
||||
You: Now explain the joke.
|
||||
Joker: The joke plays on the word "sea" (C), which pirates are famously associated with...
|
||||
|
||||
You: exit
|
||||
```
|
||||
|
||||
## Scriptable Usage
|
||||
|
||||
You can also pipe input to the app for scriptable usage:
|
||||
|
||||
```bash
|
||||
echo "Tell me a joke about a pirate." | dotnet run
|
||||
```
|
||||
|
||||
The app will read from stdin, process the input, and write the response to stdout.
|
||||
|
||||
## Viewing Agent State
|
||||
|
||||
You can view the state of the agent in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can view the state of the Joker agent, including its conversation history and current state
|
||||
|
||||
The agent maintains conversation state across multiple interactions, and you can inspect this state in the dashboard to understand how the durable agents extension manages conversation context.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>AgentOrchestration_Chaining</AssemblyName>
|
||||
<RootNamespace>AgentOrchestration_Chaining</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AgentOrchestration_Chaining;
|
||||
|
||||
// Response model
|
||||
public sealed record TextResponse(string Text);
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentOrchestration_Chaining;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using Environment = System.Environment;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Single agent used by the orchestration to demonstrate sequential calls on the same thread.
|
||||
const string WriterName = "WriterAgent";
|
||||
const string WriterInstructions =
|
||||
"""
|
||||
You refine short pieces of text. When given an initial sentence you enhance it;
|
||||
when given an improved sentence you polish it further.
|
||||
""";
|
||||
|
||||
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterInstructions, WriterName);
|
||||
|
||||
// Orchestrator function
|
||||
static async Task<string> RunOrchestratorAsync(TaskOrchestrationContext context)
|
||||
{
|
||||
DurableAIAgent writer = context.GetAgent("WriterAgent");
|
||||
AgentThread writerThread = await writer.GetNewThreadAsync();
|
||||
|
||||
AgentResponse<TextResponse> initial = await writer.RunAsync<TextResponse>(
|
||||
message: "Write a concise inspirational sentence about learning.",
|
||||
thread: writerThread);
|
||||
|
||||
AgentResponse<TextResponse> refined = await writer.RunAsync<TextResponse>(
|
||||
message: $"Improve this further while keeping it under 25 words: {initial.Result.Text}",
|
||||
thread: writerThread);
|
||||
|
||||
return refined.Result.Text;
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agent.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options => options.AddAIAgent(writerAgent),
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
builder.AddTasks(registry => registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync));
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Single Agent Orchestration Chaining Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Starting orchestration...");
|
||||
Console.WriteLine();
|
||||
|
||||
try
|
||||
{
|
||||
// Start the orchestration
|
||||
string instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: nameof(RunOrchestratorAsync));
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
|
||||
Console.WriteLine("Waiting for completion...");
|
||||
Console.ResetColor();
|
||||
|
||||
// Wait for orchestration to complete
|
||||
OrchestrationMetadata status = await durableClient.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
CancellationToken.None);
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("✓ Orchestration completed successfully!");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("Result: ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(status.ReadOutputAs<string>());
|
||||
}
|
||||
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("✗ Orchestration failed!");
|
||||
Console.ResetColor();
|
||||
if (status.FailureDetails != null)
|
||||
{
|
||||
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
|
||||
}
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Single Agent Orchestration Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a simple console app that orchestrates sequential calls to a single AI agent using the same conversation thread for context continuity.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Orchestrating multiple interactions with the same agent in a deterministic order
|
||||
- Using the same `AgentThread` across multiple calls to maintain conversational context
|
||||
- Durable orchestration with automatic checkpointing and resumption from failures
|
||||
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
|
||||
|
||||
## 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will start the orchestration, wait for it to complete, and display the result:
|
||||
|
||||
```text
|
||||
=== Single Agent Orchestration Chaining Sample ===
|
||||
Starting orchestration...
|
||||
|
||||
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
|
||||
Waiting for completion...
|
||||
|
||||
✓ Orchestration completed successfully!
|
||||
|
||||
Result: Learning serves as the key, opening doors to boundless opportunities and a brighter future.
|
||||
```
|
||||
|
||||
The orchestration will proceed to run the WriterAgent twice in sequence:
|
||||
|
||||
1. First, it writes an inspirational sentence about learning
|
||||
2. Then, it refines the initial output using the same conversation thread
|
||||
|
||||
## Viewing Orchestration State
|
||||
|
||||
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
|
||||
- **Agents**: View the state of the WriterAgent, including conversation history maintained across the orchestration steps
|
||||
|
||||
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect its execution details, including the sequence of agent calls and their results.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>AgentOrchestration_Concurrency</AssemblyName>
|
||||
<RootNamespace>AgentOrchestration_Concurrency</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace AgentOrchestration_Concurrency;
|
||||
|
||||
// Response model
|
||||
public sealed record TextResponse(string Text);
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AgentOrchestration_Concurrency;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Two agents used by the orchestration to demonstrate concurrent execution.
|
||||
const string PhysicistName = "PhysicistAgent";
|
||||
const string PhysicistInstructions = "You are an expert in physics. You answer questions from a physics perspective.";
|
||||
|
||||
const string ChemistName = "ChemistAgent";
|
||||
const string ChemistInstructions = "You are a middle school chemistry teacher. You answer questions so that middle school students can understand.";
|
||||
|
||||
AIAgent physicistAgent = client.GetChatClient(deploymentName).AsAIAgent(PhysicistInstructions, PhysicistName);
|
||||
AIAgent chemistAgent = client.GetChatClient(deploymentName).AsAIAgent(ChemistInstructions, ChemistName);
|
||||
|
||||
// Orchestrator function
|
||||
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, string prompt)
|
||||
{
|
||||
// Get both agents
|
||||
DurableAIAgent physicist = context.GetAgent(PhysicistName);
|
||||
DurableAIAgent chemist = context.GetAgent(ChemistName);
|
||||
|
||||
// Start both agent runs concurrently
|
||||
Task<AgentResponse<TextResponse>> physicistTask = physicist.RunAsync<TextResponse>(prompt);
|
||||
Task<AgentResponse<TextResponse>> chemistTask = chemist.RunAsync<TextResponse>(prompt);
|
||||
|
||||
// Wait for both tasks to complete using Task.WhenAll
|
||||
await Task.WhenAll(physicistTask, chemistTask);
|
||||
|
||||
// Get the results
|
||||
TextResponse physicistResponse = (await physicistTask).Result;
|
||||
TextResponse chemistResponse = (await chemistTask).Result;
|
||||
|
||||
// Return the result as a structured, anonymous type
|
||||
return new
|
||||
{
|
||||
physicist = physicistResponse.Text,
|
||||
chemist = chemistResponse.Text,
|
||||
};
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agents.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
options
|
||||
.AddAIAgent(physicistAgent)
|
||||
.AddAIAgent(chemistAgent);
|
||||
},
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
builder.AddTasks(
|
||||
registry => registry.AddOrchestratorFunc<string, object>(nameof(RunOrchestratorAsync), RunOrchestratorAsync));
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Multi-Agent Concurrent Orchestration Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter a question for the agents:");
|
||||
Console.WriteLine();
|
||||
|
||||
// Read prompt from stdin
|
||||
string? prompt = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(prompt))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine("Error: Prompt is required.");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("Starting orchestration...");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
// Start the orchestration
|
||||
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: nameof(RunOrchestratorAsync),
|
||||
input: prompt);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
|
||||
Console.WriteLine("Waiting for completion...");
|
||||
Console.ResetColor();
|
||||
|
||||
// Wait for orchestration to complete
|
||||
OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
CancellationToken.None);
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("✓ Orchestration completed successfully!");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
// Parse the output
|
||||
using JsonDocument doc = JsonDocument.Parse(status.SerializedOutput!);
|
||||
JsonElement output = doc.RootElement;
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("Physicist's response:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(output.GetProperty("physicist").GetString());
|
||||
Console.WriteLine();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("Chemist's response:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(output.GetProperty("chemist").GetString());
|
||||
}
|
||||
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("✗ Orchestration failed!");
|
||||
Console.ResetColor();
|
||||
if (status.FailureDetails != null)
|
||||
{
|
||||
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
|
||||
}
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# Multi-Agent Concurrent Orchestration Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a console app that orchestrates concurrent execution of multiple AI agents using durable orchestration.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Running multiple agents concurrently in a single orchestration
|
||||
- Using `Task.WhenAll` to wait for concurrent agent executions
|
||||
- Combining results from multiple agents into a single response
|
||||
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
|
||||
|
||||
## 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for a question:
|
||||
|
||||
```text
|
||||
=== Multi-Agent Concurrent Orchestration Sample ===
|
||||
Enter a question for the agents:
|
||||
|
||||
What is temperature?
|
||||
```
|
||||
|
||||
The orchestration will run both agents concurrently and display their responses:
|
||||
|
||||
```text
|
||||
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
|
||||
Waiting for completion...
|
||||
|
||||
✓ Orchestration completed successfully!
|
||||
|
||||
Physicist's response:
|
||||
Temperature is a measure of the average kinetic energy of particles in a system...
|
||||
|
||||
Chemist's response:
|
||||
From a chemistry perspective, temperature is crucial for chemical reactions...
|
||||
```
|
||||
|
||||
Both agents run in parallel, and the orchestration waits for both to complete before returning the combined results.
|
||||
|
||||
## Viewing Orchestration State
|
||||
|
||||
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
|
||||
- **Agents**: View the state of both the PhysicistAgent and ChemistAgent, including their individual conversation histories
|
||||
|
||||
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect how the concurrent agent executions were coordinated, including the timing of when each agent started and completed.
|
||||
|
||||
## Scriptable Usage
|
||||
|
||||
You can also pipe input to the app:
|
||||
|
||||
```bash
|
||||
echo "What is temperature?" | dotnet run
|
||||
```
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>AgentOrchestration_Conditionals</AssemblyName>
|
||||
<RootNamespace>AgentOrchestration_Conditionals</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AgentOrchestration_Conditionals;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an email input for spam detection and response generation.
|
||||
/// </summary>
|
||||
public sealed class Email
|
||||
{
|
||||
[JsonPropertyName("email_id")]
|
||||
public string EmailId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("email_content")]
|
||||
public string EmailContent { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of spam detection analysis.
|
||||
/// </summary>
|
||||
public sealed class DetectionResult
|
||||
{
|
||||
[JsonPropertyName("is_spam")]
|
||||
public bool IsSpam { get; set; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a generated email response.
|
||||
/// </summary>
|
||||
public sealed class EmailResponse
|
||||
{
|
||||
[JsonPropertyName("response")]
|
||||
public string Response { get; set; } = string.Empty;
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AgentOrchestration_Conditionals;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Spam detection agent
|
||||
const string SpamDetectionAgentName = "SpamDetectionAgent";
|
||||
const string SpamDetectionAgentInstructions =
|
||||
"""
|
||||
You are an expert email spam detection system. Analyze emails and determine if they are spam.
|
||||
Return your analysis as JSON with 'is_spam' (boolean) and 'reason' (string) fields.
|
||||
""";
|
||||
|
||||
// Email assistant agent
|
||||
const string EmailAssistantAgentName = "EmailAssistantAgent";
|
||||
const string EmailAssistantAgentInstructions =
|
||||
"""
|
||||
You are a professional email assistant. Draft professional, courteous, and helpful email responses.
|
||||
Return your response as JSON with a 'response' field containing the reply.
|
||||
""";
|
||||
|
||||
AIAgent spamDetectionAgent = client.GetChatClient(deploymentName).AsAIAgent(SpamDetectionAgentInstructions, SpamDetectionAgentName);
|
||||
AIAgent emailAssistantAgent = client.GetChatClient(deploymentName).AsAIAgent(EmailAssistantAgentInstructions, EmailAssistantAgentName);
|
||||
|
||||
// Orchestrator function
|
||||
static async Task<string> RunOrchestratorAsync(TaskOrchestrationContext context, Email email)
|
||||
{
|
||||
// Get the spam detection agent
|
||||
DurableAIAgent spamDetectionAgent = context.GetAgent(SpamDetectionAgentName);
|
||||
AgentThread spamThread = await spamDetectionAgent.GetNewThreadAsync();
|
||||
|
||||
// Step 1: Check if the email is spam
|
||||
AgentResponse<DetectionResult> spamDetectionResponse = await spamDetectionAgent.RunAsync<DetectionResult>(
|
||||
message:
|
||||
$"""
|
||||
Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) and 'reason' (string) fields:
|
||||
Email ID: {email.EmailId}
|
||||
Content: {email.EmailContent}
|
||||
""",
|
||||
thread: spamThread);
|
||||
DetectionResult result = spamDetectionResponse.Result;
|
||||
|
||||
// Step 2: Conditional logic based on spam detection result
|
||||
if (result.IsSpam)
|
||||
{
|
||||
// Handle spam email
|
||||
return await context.CallActivityAsync<string>(nameof(HandleSpamEmail), result.Reason);
|
||||
}
|
||||
|
||||
// Generate and send response for legitimate email
|
||||
DurableAIAgent emailAssistantAgent = context.GetAgent(EmailAssistantAgentName);
|
||||
AgentThread emailThread = await emailAssistantAgent.GetNewThreadAsync();
|
||||
|
||||
AgentResponse<EmailResponse> emailAssistantResponse = await emailAssistantAgent.RunAsync<EmailResponse>(
|
||||
message:
|
||||
$"""
|
||||
Draft a professional response to this email. Return a JSON response with a 'response' field containing the reply:
|
||||
|
||||
Email ID: {email.EmailId}
|
||||
Content: {email.EmailContent}
|
||||
""",
|
||||
thread: emailThread);
|
||||
|
||||
EmailResponse emailResponse = emailAssistantResponse.Result;
|
||||
|
||||
return await context.CallActivityAsync<string>(nameof(SendEmail), emailResponse.Response);
|
||||
}
|
||||
|
||||
// Activity functions
|
||||
static void HandleSpamEmail(TaskActivityContext context, string reason)
|
||||
{
|
||||
Console.WriteLine($"Email marked as spam: {reason}");
|
||||
}
|
||||
|
||||
static void SendEmail(TaskActivityContext context, string message)
|
||||
{
|
||||
Console.WriteLine($"Email sent: {message}");
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agents.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
options
|
||||
.AddAIAgent(spamDetectionAgent)
|
||||
.AddAIAgent(emailAssistantAgent);
|
||||
},
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
builder.AddTasks(registry =>
|
||||
{
|
||||
registry.AddOrchestratorFunc<Email>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
|
||||
registry.AddActivityFunc<string>(nameof(HandleSpamEmail), HandleSpamEmail);
|
||||
registry.AddActivityFunc<string>(nameof(SendEmail), SendEmail);
|
||||
});
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Multi-Agent Conditional Orchestration Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter email content:");
|
||||
Console.WriteLine();
|
||||
|
||||
// Read email content from stdin
|
||||
string? emailContent = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(emailContent))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine("Error: Email content is required.");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate email ID automatically
|
||||
Email email = new()
|
||||
{
|
||||
EmailId = $"email-{Guid.NewGuid():N}",
|
||||
EmailContent = emailContent
|
||||
};
|
||||
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("Starting orchestration...");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
// Start the orchestration
|
||||
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: nameof(RunOrchestratorAsync),
|
||||
input: email);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
|
||||
Console.WriteLine("Waiting for completion...");
|
||||
Console.ResetColor();
|
||||
|
||||
// Wait for orchestration to complete
|
||||
OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
CancellationToken.None);
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("✓ Orchestration completed successfully!");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("Result: ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(status.ReadOutputAs<string>());
|
||||
}
|
||||
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("✗ Orchestration failed!");
|
||||
Console.ResetColor();
|
||||
if (status.FailureDetails != null)
|
||||
{
|
||||
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
|
||||
}
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Multi-Agent Conditional Orchestration Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a console app that orchestrates multiple AI agents with conditional logic based on the results of previous agent interactions.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Multi-agent orchestration with conditional branching
|
||||
- Using agent responses to determine workflow paths
|
||||
- Activity functions for non-agent operations
|
||||
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
|
||||
|
||||
## 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for email content. You can test both legitimate emails and spam emails:
|
||||
|
||||
### Testing with a Legitimate Email
|
||||
|
||||
```text
|
||||
=== Multi-Agent Conditional Orchestration Sample ===
|
||||
Enter email content:
|
||||
|
||||
Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!
|
||||
```
|
||||
|
||||
The orchestration will analyze the email and display the result:
|
||||
|
||||
```text
|
||||
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
|
||||
Waiting for completion...
|
||||
|
||||
✓ Orchestration completed successfully!
|
||||
|
||||
Result: Email sent: Thank you for your email. I'll prepare the updated figures...
|
||||
```
|
||||
|
||||
### Testing with a Spam Email
|
||||
|
||||
```text
|
||||
=== Multi-Agent Conditional Orchestration Sample ===
|
||||
Enter email content:
|
||||
|
||||
URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!
|
||||
```
|
||||
|
||||
The orchestration will detect it as spam and display:
|
||||
|
||||
```text
|
||||
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
|
||||
Waiting for completion...
|
||||
|
||||
✓ Orchestration completed successfully!
|
||||
|
||||
Result: Email marked as spam: Contains suspicious claims about winning money and urgent action requests...
|
||||
```
|
||||
|
||||
## Scriptable Usage
|
||||
|
||||
You can also pipe email content to the app:
|
||||
|
||||
```bash
|
||||
# Test with a legitimate email
|
||||
echo "Hi John, I hope you're doing well..." | dotnet run
|
||||
|
||||
# Test with a spam email
|
||||
echo "URGENT! You've won $1,000,000! Click here now!" | dotnet run
|
||||
```
|
||||
|
||||
The orchestration will proceed as follows:
|
||||
|
||||
1. The SpamDetectionAgent analyzes the email to determine if it's spam
|
||||
2. Based on the result:
|
||||
- If spam: The orchestration calls the `HandleSpamEmail` activity function
|
||||
- If not spam: The EmailAssistantAgent drafts a response, then the `SendEmail` activity function is called
|
||||
|
||||
## Viewing Orchestration State
|
||||
|
||||
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
|
||||
- **Agents**: View the state of both the SpamDetectionAgent and EmailAssistantAgent
|
||||
|
||||
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect the conditional branching logic, including which path was taken based on the spam detection result.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>AgentOrchestration_HITL</AssemblyName>
|
||||
<RootNamespace>AgentOrchestration_HITL</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AgentOrchestration_HITL;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the input for the Human-in-the-Loop content generation workflow.
|
||||
/// </summary>
|
||||
public sealed class ContentGenerationInput
|
||||
{
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("max_review_attempts")]
|
||||
public int MaxReviewAttempts { get; set; } = 3;
|
||||
|
||||
[JsonPropertyName("approval_timeout_hours")]
|
||||
public float ApprovalTimeoutHours { get; set; } = 72;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the content generated by the writer agent.
|
||||
/// </summary>
|
||||
public sealed class GeneratedContent
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the human approval response.
|
||||
/// </summary>
|
||||
public sealed class HumanApprovalResponse
|
||||
{
|
||||
[JsonPropertyName("approved")]
|
||||
public bool Approved { get; set; }
|
||||
|
||||
[JsonPropertyName("feedback")]
|
||||
public string Feedback { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AgentOrchestration_HITL;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Single agent used by the orchestration to demonstrate human-in-the-loop workflow.
|
||||
const string WriterName = "WriterAgent";
|
||||
const string WriterInstructions =
|
||||
"""
|
||||
You are a professional content writer who creates high-quality articles on various topics.
|
||||
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
|
||||
""";
|
||||
|
||||
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterInstructions, WriterName);
|
||||
|
||||
// Orchestrator function
|
||||
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input)
|
||||
{
|
||||
// Get the writer agent
|
||||
DurableAIAgent writerAgent = context.GetAgent("WriterAgent");
|
||||
AgentThread writerThread = await writerAgent.GetNewThreadAsync();
|
||||
|
||||
// Set initial status
|
||||
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
|
||||
|
||||
// Step 1: Generate initial content
|
||||
AgentResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
|
||||
message: $"Write a short article about '{input.Topic}' in less than 300 words.",
|
||||
thread: writerThread);
|
||||
GeneratedContent content = writerResponse.Result;
|
||||
|
||||
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
|
||||
int iterationCount = 0;
|
||||
while (iterationCount++ < input.MaxReviewAttempts)
|
||||
{
|
||||
context.SetCustomStatus(
|
||||
$"Requesting human feedback. Iteration #{iterationCount}. Timeout: {input.ApprovalTimeoutHours} hour(s).");
|
||||
|
||||
// Step 2: Notify user to review the content
|
||||
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
|
||||
|
||||
// Step 3: Wait for human feedback with configurable timeout
|
||||
HumanApprovalResponse humanResponse;
|
||||
try
|
||||
{
|
||||
humanResponse = await context.WaitForExternalEvent<HumanApprovalResponse>(
|
||||
eventName: "HumanApproval",
|
||||
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout occurred - treat as rejection
|
||||
context.SetCustomStatus(
|
||||
$"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.");
|
||||
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
|
||||
}
|
||||
|
||||
if (humanResponse.Approved)
|
||||
{
|
||||
context.SetCustomStatus("Content approved by human reviewer. Publishing content...");
|
||||
|
||||
// Step 4: Publish the approved content
|
||||
await context.CallActivityAsync(nameof(PublishContent), content);
|
||||
|
||||
context.SetCustomStatus($"Content published successfully at {context.CurrentUtcDateTime:s}");
|
||||
return new { content = content.Content };
|
||||
}
|
||||
|
||||
context.SetCustomStatus("Content rejected by human reviewer. Incorporating feedback and regenerating...");
|
||||
|
||||
// Incorporate human feedback and regenerate
|
||||
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
|
||||
message: $"""
|
||||
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
|
||||
|
||||
Human Feedback: {humanResponse.Feedback}
|
||||
""",
|
||||
thread: writerThread);
|
||||
|
||||
content = writerResponse.Result;
|
||||
}
|
||||
|
||||
// If we reach here, it means we exhausted the maximum number of iterations
|
||||
throw new InvalidOperationException(
|
||||
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
|
||||
}
|
||||
|
||||
// Activity functions
|
||||
static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content)
|
||||
{
|
||||
// In a real implementation, this would send notifications via email, SMS, etc.
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
NOTIFICATION: Please review the following content for approval:
|
||||
Title: {content.Title}
|
||||
Content: {content.Content}
|
||||
Use the approval endpoint to approve or reject this content.
|
||||
""");
|
||||
}
|
||||
|
||||
static void PublishContent(TaskActivityContext context, GeneratedContent content)
|
||||
{
|
||||
// In a real implementation, this would publish to a CMS, website, etc.
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
PUBLISHING: Content has been published successfully.
|
||||
Title: {content.Title}
|
||||
Content: {content.Content}
|
||||
""");
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agent.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options => options.AddAIAgent(writerAgent),
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
builder.AddTasks(registry =>
|
||||
{
|
||||
registry.AddOrchestratorFunc<ContentGenerationInput>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
|
||||
registry.AddActivityFunc<GeneratedContent>(nameof(NotifyUserForApproval), NotifyUserForApproval);
|
||||
registry.AddActivityFunc<GeneratedContent>(nameof(PublishContent), PublishContent);
|
||||
});
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Human-in-the-Loop Orchestration Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter topic for content generation:");
|
||||
Console.WriteLine();
|
||||
|
||||
// Read topic from stdin
|
||||
string? topic = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine("Error: Topic is required.");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prompt for optional parameters with defaults
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Max review attempts (default: 3):");
|
||||
string? maxAttemptsInput = Console.ReadLine();
|
||||
int maxReviewAttempts = int.TryParse(maxAttemptsInput, out int maxAttempts) && maxAttempts > 0
|
||||
? maxAttempts
|
||||
: 3;
|
||||
|
||||
Console.WriteLine("Approval timeout in hours (default: 72):");
|
||||
string? timeoutInput = Console.ReadLine();
|
||||
float approvalTimeoutHours = float.TryParse(timeoutInput, out float timeout) && timeout > 0
|
||||
? timeout
|
||||
: 72;
|
||||
|
||||
ContentGenerationInput input = new()
|
||||
{
|
||||
Topic = topic,
|
||||
MaxReviewAttempts = maxReviewAttempts,
|
||||
ApprovalTimeoutHours = approvalTimeoutHours
|
||||
};
|
||||
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine("Starting orchestration...");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
// Start the orchestration
|
||||
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
|
||||
orchestratorName: nameof(RunOrchestratorAsync),
|
||||
input: input);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Gray;
|
||||
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
|
||||
Console.WriteLine("Waiting for human approval...");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
// Monitor orchestration status and handle approval prompts
|
||||
using CancellationTokenSource cts = new();
|
||||
Task orchestrationTask = Task.Run(async () =>
|
||||
{
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
OrchestrationMetadata? status = await durableTaskClient.GetInstanceAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
cts.Token);
|
||||
|
||||
if (status == null)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're waiting for approval
|
||||
if (status.SerializedCustomStatus != null)
|
||||
{
|
||||
string? customStatus = status.ReadCustomStatusAs<string>();
|
||||
if (customStatus?.StartsWith("Requesting human feedback", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Prompt user for approval
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("Content is ready for review. Check the logs above for details.");
|
||||
Console.Write("Approve? (y/n): ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? approvalInput = Console.ReadLine();
|
||||
bool approved = approvalInput?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
Console.Write("Feedback (optional): ");
|
||||
string? feedback = Console.ReadLine() ?? "";
|
||||
|
||||
HumanApprovalResponse approvalResponse = new()
|
||||
{
|
||||
Approved = approved,
|
||||
Feedback = feedback
|
||||
};
|
||||
|
||||
await durableTaskClient.RaiseEventAsync(instanceId, "HumanApproval", approvalResponse);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.RuntimeStatus is OrchestrationRuntimeStatus.Completed or OrchestrationRuntimeStatus.Failed or OrchestrationRuntimeStatus.Terminated)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
|
||||
}
|
||||
}, cts.Token);
|
||||
|
||||
// Wait for orchestration to complete
|
||||
OrchestrationMetadata finalStatus = await durableTaskClient.WaitForInstanceCompletionAsync(
|
||||
instanceId,
|
||||
getInputsAndOutputs: true,
|
||||
CancellationToken.None);
|
||||
|
||||
cts.Cancel();
|
||||
await orchestrationTask;
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("✓ Orchestration completed successfully!");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
|
||||
JsonElement output = finalStatus.ReadOutputAs<JsonElement>();
|
||||
if (output.TryGetProperty("content", out JsonElement contentElement))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("Published content:");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(contentElement.GetString());
|
||||
}
|
||||
}
|
||||
else if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine("✗ Orchestration failed!");
|
||||
Console.ResetColor();
|
||||
if (finalStatus.FailureDetails != null)
|
||||
{
|
||||
Console.WriteLine($"Error: {finalStatus.FailureDetails.ErrorMessage}");
|
||||
}
|
||||
Environment.Exit(1);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"Orchestration status: {finalStatus.RuntimeStatus}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await host.StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# Human-in-the-Loop Orchestration Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a console app that implements a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Human-in-the-loop workflows with durable orchestration
|
||||
- External event handling for human approval/rejection
|
||||
- Timeout handling for approval requests
|
||||
- Iterative content refinement based on human feedback
|
||||
|
||||
## 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for input:
|
||||
|
||||
```text
|
||||
=== Human-in-the-Loop Orchestration Sample ===
|
||||
Enter topic for content generation:
|
||||
|
||||
The Future of Artificial Intelligence
|
||||
|
||||
Max review attempts (default: 3):
|
||||
3
|
||||
Approval timeout in hours (default: 72):
|
||||
72
|
||||
```
|
||||
|
||||
The orchestration will generate content and prompt you for approval:
|
||||
|
||||
```text
|
||||
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
|
||||
|
||||
=== NOTIFICATION: Content Ready for Review ===
|
||||
Title: The Future of Artificial Intelligence
|
||||
|
||||
Content:
|
||||
[Generated content appears here]
|
||||
|
||||
Please review the content above and provide your approval.
|
||||
|
||||
Content is ready for review. Check the logs above for details.
|
||||
Approve? (y/n): n
|
||||
Feedback (optional): Please add more details about the ethical implications.
|
||||
```
|
||||
|
||||
The orchestration will incorporate your feedback and regenerate the content. Once approved, it will publish and complete.
|
||||
|
||||
## Viewing Orchestration State
|
||||
|
||||
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Orchestrations**: View the orchestration instance, including its runtime status, custom status (which shows approval state), input, output, and execution history
|
||||
- **Agents**: View the state of the WriterAgent, including conversation history
|
||||
|
||||
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect:
|
||||
|
||||
- The custom status field, which shows the current state of the approval workflow
|
||||
- When the orchestration is waiting for external events
|
||||
- The iteration count and feedback history
|
||||
- The final published content
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>LongRunningTools</AssemblyName>
|
||||
<RootNamespace>LongRunningTools</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LongRunningTools;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the input for the content generation workflow.
|
||||
/// </summary>
|
||||
public sealed class ContentGenerationInput
|
||||
{
|
||||
[JsonPropertyName("topic")]
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("max_review_attempts")]
|
||||
public int MaxReviewAttempts { get; set; } = 3;
|
||||
|
||||
[JsonPropertyName("approval_timeout_hours")]
|
||||
public float ApprovalTimeoutHours { get; set; } = 72;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the content generated by the writer agent.
|
||||
/// </summary>
|
||||
public sealed class GeneratedContent
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("content")]
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the human feedback response.
|
||||
/// </summary>
|
||||
public sealed class HumanFeedbackResponse
|
||||
{
|
||||
[JsonPropertyName("approved")]
|
||||
public bool Approved { get; set; }
|
||||
|
||||
[JsonPropertyName("feedback")]
|
||||
public string Feedback { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using LongRunningTools;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask;
|
||||
using Microsoft.DurableTask.Client;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Agent used by the orchestration to write content.
|
||||
const string WriterAgentName = "Writer";
|
||||
const string WriterAgentInstructions =
|
||||
"""
|
||||
You are a professional content writer who creates high-quality articles on various topics.
|
||||
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
|
||||
""";
|
||||
|
||||
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterAgentInstructions, WriterAgentName);
|
||||
|
||||
// Agent that can start content generation workflows using tools
|
||||
const string PublisherAgentName = "Publisher";
|
||||
const string PublisherAgentInstructions =
|
||||
"""
|
||||
You are a publishing agent that can manage content generation workflows.
|
||||
You have access to tools to start, monitor, and raise events for content generation workflows.
|
||||
""";
|
||||
|
||||
const string HumanFeedbackEventName = "HumanFeedback";
|
||||
|
||||
// Orchestrator function
|
||||
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input)
|
||||
{
|
||||
// Get the writer agent
|
||||
DurableAIAgent writerAgent = context.GetAgent(WriterAgentName);
|
||||
AgentThread writerThread = await writerAgent.GetNewThreadAsync();
|
||||
|
||||
// Set initial status
|
||||
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
|
||||
|
||||
// Step 1: Generate initial content
|
||||
AgentResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
|
||||
message: $"Write a short article about '{input.Topic}'.",
|
||||
thread: writerThread);
|
||||
GeneratedContent content = writerResponse.Result;
|
||||
|
||||
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
|
||||
int iterationCount = 0;
|
||||
while (iterationCount++ < input.MaxReviewAttempts)
|
||||
{
|
||||
context.SetCustomStatus(
|
||||
new
|
||||
{
|
||||
message = "Requesting human feedback.",
|
||||
approvalTimeoutHours = input.ApprovalTimeoutHours,
|
||||
iterationCount,
|
||||
content
|
||||
});
|
||||
|
||||
// Step 2: Notify user to review the content
|
||||
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
|
||||
|
||||
// Step 3: Wait for human feedback with configurable timeout
|
||||
HumanFeedbackResponse humanResponse;
|
||||
try
|
||||
{
|
||||
humanResponse = await context.WaitForExternalEvent<HumanFeedbackResponse>(
|
||||
eventName: HumanFeedbackEventName,
|
||||
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout occurred - treat as rejection
|
||||
context.SetCustomStatus(
|
||||
new
|
||||
{
|
||||
message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.",
|
||||
iterationCount,
|
||||
content
|
||||
});
|
||||
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
|
||||
}
|
||||
|
||||
if (humanResponse.Approved)
|
||||
{
|
||||
context.SetCustomStatus(new
|
||||
{
|
||||
message = "Content approved by human reviewer. Publishing content...",
|
||||
content
|
||||
});
|
||||
|
||||
// Step 4: Publish the approved content
|
||||
await context.CallActivityAsync(nameof(PublishContent), content);
|
||||
|
||||
context.SetCustomStatus(new
|
||||
{
|
||||
message = $"Content published successfully at {context.CurrentUtcDateTime:s}",
|
||||
humanFeedback = humanResponse,
|
||||
content
|
||||
});
|
||||
return new { content = content.Content };
|
||||
}
|
||||
|
||||
context.SetCustomStatus(new
|
||||
{
|
||||
message = "Content rejected by human reviewer. Incorporating feedback and regenerating...",
|
||||
humanFeedback = humanResponse,
|
||||
content
|
||||
});
|
||||
|
||||
// Incorporate human feedback and regenerate
|
||||
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
|
||||
message: $"""
|
||||
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
|
||||
|
||||
Human Feedback: {humanResponse.Feedback}
|
||||
""",
|
||||
thread: writerThread);
|
||||
|
||||
content = writerResponse.Result;
|
||||
}
|
||||
|
||||
// If we reach here, it means we exhausted the maximum number of iterations
|
||||
throw new InvalidOperationException(
|
||||
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
|
||||
}
|
||||
|
||||
// Activity functions
|
||||
static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content)
|
||||
{
|
||||
// In a real implementation, this would send notifications via email, SMS, etc.
|
||||
Console.ForegroundColor = ConsoleColor.DarkMagenta;
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
NOTIFICATION: Please review the following content for approval:
|
||||
Title: {content.Title}
|
||||
Content: {content.Content}
|
||||
""");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
static void PublishContent(TaskActivityContext context, GeneratedContent content)
|
||||
{
|
||||
// In a real implementation, this would publish to a CMS, website, etc.
|
||||
Console.ForegroundColor = ConsoleColor.DarkMagenta;
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
PUBLISHING: Content has been published successfully.
|
||||
Title: {content.Title}
|
||||
Content: {content.Content}
|
||||
""");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// Tools that demonstrate starting orchestrations from agent tool calls.
|
||||
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
|
||||
static string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
|
||||
{
|
||||
const int MaxReviewAttempts = 3;
|
||||
const float ApprovalTimeoutHours = 72;
|
||||
|
||||
// Schedule the orchestration, which will start running after the tool call completes.
|
||||
string instanceId = DurableAgentContext.Current.ScheduleNewOrchestration(
|
||||
name: nameof(RunOrchestratorAsync),
|
||||
input: new ContentGenerationInput
|
||||
{
|
||||
Topic = topic,
|
||||
MaxReviewAttempts = MaxReviewAttempts,
|
||||
ApprovalTimeoutHours = ApprovalTimeoutHours
|
||||
});
|
||||
|
||||
return $"Workflow started with instance ID: {instanceId}";
|
||||
}
|
||||
|
||||
[Description("Gets the status of a workflow orchestration and returns a summary of the workflow's current status.")]
|
||||
static async Task<object> GetWorkflowStatusAsync(
|
||||
[Description("The instance ID of the workflow to check")] string instanceId,
|
||||
[Description("Whether to include detailed information")] bool includeDetails = true)
|
||||
{
|
||||
// Get the current agent context using the thread-static property
|
||||
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
|
||||
instanceId,
|
||||
includeDetails);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
return new
|
||||
{
|
||||
instanceId,
|
||||
error = $"Workflow instance '{instanceId}' not found.",
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
instanceId = status.InstanceId,
|
||||
createdAt = status.CreatedAt,
|
||||
executionStatus = status.RuntimeStatus,
|
||||
workflowStatus = status.SerializedCustomStatus,
|
||||
lastUpdatedAt = status.LastUpdatedAt,
|
||||
failureDetails = status.FailureDetails
|
||||
};
|
||||
}
|
||||
|
||||
[Description(
|
||||
"Raises a feedback event for the content generation workflow. If approved, the workflow will be published. " +
|
||||
"If rejected, the workflow will generate new content.")]
|
||||
static async Task SubmitHumanFeedbackAsync(
|
||||
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
|
||||
[Description("Feedback to submit")] HumanFeedbackResponse feedback)
|
||||
{
|
||||
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, HumanFeedbackEventName, feedback);
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agents.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
// Add the writer agent used by the orchestration
|
||||
options.AddAIAgent(writerAgent);
|
||||
|
||||
// Define the agent that can start orchestrations from tool calls
|
||||
options.AddAIAgentFactory(PublisherAgentName, sp =>
|
||||
{
|
||||
return client.GetChatClient(deploymentName).AsAIAgent(
|
||||
instructions: PublisherAgentInstructions,
|
||||
name: PublisherAgentName,
|
||||
services: sp,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(StartContentGenerationWorkflow),
|
||||
AIFunctionFactory.Create(GetWorkflowStatusAsync),
|
||||
AIFunctionFactory.Create(SubmitHumanFeedbackAsync),
|
||||
]);
|
||||
});
|
||||
},
|
||||
workerBuilder: builder =>
|
||||
{
|
||||
builder.UseDurableTaskScheduler(dtsConnectionString);
|
||||
builder.AddTasks(registry =>
|
||||
{
|
||||
registry.AddOrchestratorFunc<ContentGenerationInput>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
|
||||
registry.AddActivityFunc<GeneratedContent>(nameof(NotifyUserForApproval), NotifyUserForApproval);
|
||||
registry.AddActivityFunc<GeneratedContent>(nameof(PublishContent), PublishContent);
|
||||
});
|
||||
},
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the agent proxy from services
|
||||
IServiceProvider services = host.Services;
|
||||
AIAgent? agentProxy = services.GetKeyedService<AIAgent>(PublisherAgentName);
|
||||
if (agentProxy == null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine("Agent 'Publisher' not found.");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Long Running Tools Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter a topic for the Publisher agent to write about (or 'exit' to quit):");
|
||||
Console.WriteLine();
|
||||
|
||||
// Create a thread for the conversation
|
||||
AgentThread thread = await agentProxy.GetNewThreadAsync();
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
Console.CancelKeyPress += (sender, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
|
||||
while (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
// Read input from stdin
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("You: ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Run the agent
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.Write("Publisher: ");
|
||||
Console.ResetColor();
|
||||
|
||||
try
|
||||
{
|
||||
AgentResponse agentResponse = await agentProxy.RunAsync(
|
||||
message: input,
|
||||
thread: thread,
|
||||
cancellationToken: cts.Token);
|
||||
|
||||
Console.WriteLine(agentResponse.Text);
|
||||
Console.WriteLine();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
Console.WriteLine("(Press Enter to prompt the Publisher agent again)");
|
||||
_ = Console.ReadLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
@@ -0,0 +1,90 @@
|
||||
# Long Running Tools Sample
|
||||
|
||||
This sample demonstrates how to use the durable agents extension to create a console app with agents that have long running tools. This sample builds on the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample by adding a publisher agent that can start and manage content generation workflows. A key difference is that the publisher agent knows the IDs of the workflows it starts, so it can check the status of the workflows and approve or reject them without being explicitly given the context (instance IDs, etc).
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
The same key concepts as the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample are demonstrated, but with the following additional concepts:
|
||||
|
||||
- **Long running tools**: Using `DurableAgentContext.Current` to start orchestrations from tool calls
|
||||
- **Multi-agent orchestration**: Agents can start and manage workflows that orchestrate other agents
|
||||
- **Human-in-the-loop (with delegation)**: The agent acts as an intermediary between the human and the workflow. The human remains in the loop, but delegates to the agent to start the workflow and approve or reject the content.
|
||||
|
||||
## 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, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/06_LongRunningTools
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for input. You can interact with the Publisher agent:
|
||||
|
||||
```text
|
||||
=== Long Running Tools Sample ===
|
||||
Enter a topic for the Publisher agent to write about (or 'exit' to quit):
|
||||
|
||||
You: Start a content generation workflow for the topic 'The Future of Artificial Intelligence'
|
||||
Publisher: The content generation workflow for the topic "The Future of Artificial Intelligence" has been successfully started, and the instance ID is **6a04276e8d824d8d941e1dc4142cc254**. If you need any further assistance or updates on the workflow, feel free to ask!
|
||||
```
|
||||
|
||||
Behind the scenes, the publisher agent will:
|
||||
|
||||
1. Start the content generation workflow via a tool call
|
||||
2. The workflow will generate initial content using the Writer agent and wait for human approval, which will be visible in the terminal
|
||||
|
||||
Once the workflow is waiting for human approval, you can send approval or rejection by prompting the publisher agent accordingly.
|
||||
|
||||
> [!NOTE]
|
||||
> You must press Enter after each message to continue the conversation. The sample is set up this way because the workflow is running in the background and may write to the console asynchronously.
|
||||
|
||||
To tell the agent to rewrite the content with feedback, you can prompt it to reject the content with feedback.
|
||||
|
||||
```text
|
||||
You: Reject the content with feedback: The article needs more technical depth and better examples.
|
||||
Publisher: The content has been successfully rejected with the feedback: "The article needs more technical depth and better examples." The workflow will now generate new content based on this feedback.
|
||||
```
|
||||
|
||||
Once you're satisfied with the content, you can approve it for publishing.
|
||||
|
||||
```text
|
||||
You: Approve the content
|
||||
Publisher: The content has been successfully approved for publishing. If you need any more assistance or have further requests, feel free to let me know!
|
||||
```
|
||||
|
||||
Once the workflow has completed, you can get the status by prompting the publisher agent to give you the status.
|
||||
|
||||
```text
|
||||
You: Get the status of the workflow you previously started
|
||||
Publisher: The status of the workflow with instance ID **6a04276e8d824d8d941e1dc4142cc254** is as follows:
|
||||
|
||||
- **Execution Status:** Completed
|
||||
- **Created At:** December 22, 2025, 23:08:13 UTC
|
||||
- **Last Updated At:** December 22, 2025, 23:09:59 UTC
|
||||
- **Workflow Status:**
|
||||
- Message: Content published successfully at December 22, 2025, 23:09:59 UTC
|
||||
- Human Feedback: Approved
|
||||
```
|
||||
|
||||
## Viewing Agent and Orchestration State
|
||||
|
||||
You can view the state of both the agent and the orchestrations it starts in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Agents**: View the state of the Publisher agent, including its conversation history and tool call history
|
||||
- **Orchestrations**: View the content generation orchestration instances that were started by the agent via tool calls, including their runtime status, custom status, input, output, and execution history
|
||||
|
||||
When the publisher agent starts a workflow, the orchestration instance ID is included in the agent's response. You can use this ID to find the specific orchestration in the dashboard and inspect:
|
||||
|
||||
- The orchestration's execution progress
|
||||
- When it's waiting for human approval (visible in custom status)
|
||||
- The content generation workflow state
|
||||
- The WriterAgent state within the orchestration
|
||||
|
||||
This demonstrates how agents can manage long-running workflows and how you can monitor both the agent's state and the workflows it orchestrates.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>ReliableStreaming</AssemblyName>
|
||||
<RootNamespace>ReliableStreaming</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</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.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,363 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams.
|
||||
// It reads prompts from stdin and streams agent responses to stdout in real-time.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using ReliableStreaming;
|
||||
using StackExchange.Redis;
|
||||
|
||||
// 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.");
|
||||
|
||||
// Get Redis connection string from environment variable.
|
||||
string redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING")
|
||||
?? "localhost:6379";
|
||||
|
||||
// Get the Redis stream TTL from environment variable (default: 10 minutes).
|
||||
int redisStreamTtlMinutes = int.Parse(Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES") ?? "10");
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
|
||||
string? azureOpenAiKey = 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());
|
||||
|
||||
// Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming.
|
||||
const string TravelPlannerName = "TravelPlanner";
|
||||
const string TravelPlannerInstructions =
|
||||
"""
|
||||
You are an expert travel planner who creates detailed, personalized travel itineraries.
|
||||
When asked to plan a trip, you should:
|
||||
1. Create a comprehensive day-by-day itinerary
|
||||
2. Include specific recommendations for activities, restaurants, and attractions
|
||||
3. Provide practical tips for each destination
|
||||
4. Consider weather and local events when making recommendations
|
||||
5. Include estimated times and logistics between activities
|
||||
|
||||
Always use the available tools to get current weather forecasts and local events
|
||||
for the destination to make your recommendations more relevant and timely.
|
||||
|
||||
Format your response with clear headings for each day and include emoji icons
|
||||
to make the itinerary easy to scan and visually appealing.
|
||||
""";
|
||||
|
||||
// Mock travel tools that return hardcoded data for demonstration purposes.
|
||||
[Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")]
|
||||
static string GetWeatherForecast(string destination, string date)
|
||||
{
|
||||
Dictionary<string, (string condition, int highF, int lowF)> weatherByRegion = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tokyo"] = ("Partly cloudy with a chance of light rain", 58, 45),
|
||||
["Paris"] = ("Overcast with occasional drizzle", 52, 41),
|
||||
["New York"] = ("Clear and cold", 42, 28),
|
||||
["London"] = ("Foggy morning, clearing in afternoon", 48, 38),
|
||||
["Sydney"] = ("Sunny and warm", 82, 68),
|
||||
["Rome"] = ("Sunny with light breeze", 62, 48),
|
||||
["Barcelona"] = ("Partly sunny", 59, 47),
|
||||
["Amsterdam"] = ("Cloudy with light rain", 46, 38),
|
||||
["Dubai"] = ("Sunny and hot", 85, 72),
|
||||
["Singapore"] = ("Tropical thunderstorms in afternoon", 88, 77),
|
||||
["Bangkok"] = ("Hot and humid, afternoon showers", 91, 78),
|
||||
["Los Angeles"] = ("Sunny and pleasant", 72, 55),
|
||||
["San Francisco"] = ("Morning fog, afternoon sun", 62, 52),
|
||||
["Seattle"] = ("Rainy with breaks", 48, 40),
|
||||
["Miami"] = ("Warm and sunny", 78, 65),
|
||||
["Honolulu"] = ("Tropical paradise weather", 82, 72),
|
||||
};
|
||||
|
||||
(string condition, int highF, int lowF) forecast = ("Partly cloudy", 65, 50);
|
||||
foreach (KeyValuePair<string, (string, int, int)> entry in weatherByRegion)
|
||||
{
|
||||
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forecast = entry.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $"""
|
||||
Weather forecast for {destination} on {date}:
|
||||
Conditions: {forecast.condition}
|
||||
High: {forecast.highF}°F ({(forecast.highF - 32) * 5 / 9}°C)
|
||||
Low: {forecast.lowF}°F ({(forecast.lowF - 32) * 5 / 9}°C)
|
||||
|
||||
Recommendation: {GetWeatherRecommendation(forecast.condition)}
|
||||
""";
|
||||
}
|
||||
|
||||
[Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")]
|
||||
static string GetLocalEvents(string destination, string date)
|
||||
{
|
||||
Dictionary<string, string[]> eventsByCity = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tokyo"] = [
|
||||
"🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama",
|
||||
"🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays",
|
||||
"🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan",
|
||||
"🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology",
|
||||
],
|
||||
["Paris"] = [
|
||||
"🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours",
|
||||
"🍷 Wine Tasting Tour in Le Marais - Local sommelier guided",
|
||||
"🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club",
|
||||
"🥐 French Pastry Workshop - Learn from master pâtissiers",
|
||||
],
|
||||
["New York"] = [
|
||||
"🎭 Broadway Show: Hamilton - Limited engagement performances",
|
||||
"🏀 Knicks vs Lakers at Madison Square Garden",
|
||||
"🎨 Modern Art Exhibit at MoMA - New installations",
|
||||
"🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias",
|
||||
],
|
||||
["London"] = [
|
||||
"👑 Royal Collection Exhibition at Buckingham Palace",
|
||||
"🎭 West End Musical: The Phantom of the Opera",
|
||||
"🍺 Craft Beer Festival at Brick Lane",
|
||||
"🎪 Winter Wonderland at Hyde Park - Rides and markets",
|
||||
],
|
||||
["Sydney"] = [
|
||||
"🏄 Pro Surfing Competition at Bondi Beach",
|
||||
"🎵 Opera at Sydney Opera House - La Bohème",
|
||||
"🦘 Wildlife Night Safari at Taronga Zoo",
|
||||
"🍽️ Harbor Dinner Cruise with fireworks",
|
||||
],
|
||||
["Rome"] = [
|
||||
"🏛️ After-Hours Vatican Tour - Skip the crowds",
|
||||
"🍝 Pasta Making Class in Trastevere",
|
||||
"🎵 Classical Concert at Borghese Gallery",
|
||||
"🍷 Wine Tasting in Roman Cellars",
|
||||
],
|
||||
};
|
||||
|
||||
string[] events = [
|
||||
"🎭 Local theater performance",
|
||||
"🍽️ Food and wine festival",
|
||||
"🎨 Art gallery opening",
|
||||
"🎵 Live music at local venues",
|
||||
];
|
||||
|
||||
foreach (KeyValuePair<string, string[]> entry in eventsByCity)
|
||||
{
|
||||
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
events = entry.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string eventList = string.Join("\n• ", events);
|
||||
return $"""
|
||||
Local events in {destination} around {date}:
|
||||
|
||||
• {eventList}
|
||||
|
||||
💡 Tip: Book popular events in advance as they may sell out quickly!
|
||||
""";
|
||||
}
|
||||
|
||||
static string GetWeatherRecommendation(string condition)
|
||||
{
|
||||
return condition switch
|
||||
{
|
||||
string c when c.Contains("rain", StringComparison.OrdinalIgnoreCase) || c.Contains("drizzle", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Bring an umbrella and waterproof jacket. Consider indoor activities for backup.",
|
||||
string c when c.Contains("fog", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Morning visibility may be limited. Plan outdoor sightseeing for afternoon.",
|
||||
string c when c.Contains("cold", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Layer up with warm clothing. Hot drinks and cozy cafés recommended.",
|
||||
string c when c.Contains("hot", StringComparison.OrdinalIgnoreCase) || c.Contains("warm", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours.",
|
||||
string c when c.Contains("thunder", StringComparison.OrdinalIgnoreCase) || c.Contains("storm", StringComparison.OrdinalIgnoreCase) =>
|
||||
"Keep an eye on weather updates. Have indoor alternatives ready.",
|
||||
_ => "Pleasant conditions expected. Great day for outdoor exploration!"
|
||||
};
|
||||
}
|
||||
|
||||
// Configure the console app to host the AI agent.
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableAgents(
|
||||
options =>
|
||||
{
|
||||
// Define the Travel Planner agent with tools for weather and events
|
||||
options.AddAIAgentFactory(TravelPlannerName, sp =>
|
||||
{
|
||||
return client.GetChatClient(deploymentName).AsAIAgent(
|
||||
instructions: TravelPlannerInstructions,
|
||||
name: TravelPlannerName,
|
||||
services: sp,
|
||||
tools: [
|
||||
AIFunctionFactory.Create(GetWeatherForecast),
|
||||
AIFunctionFactory.Create(GetLocalEvents),
|
||||
]);
|
||||
});
|
||||
},
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
|
||||
// Register Redis connection as a singleton
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(redisConnectionString));
|
||||
|
||||
// Register the Redis stream response handler - this captures agent responses
|
||||
// and publishes them to Redis Streams for reliable delivery.
|
||||
services.AddSingleton(sp =>
|
||||
new RedisStreamResponseHandler(
|
||||
sp.GetRequiredService<IConnectionMultiplexer>(),
|
||||
TimeSpan.FromMinutes(redisStreamTtlMinutes)));
|
||||
services.AddSingleton<IAgentResponseHandler>(sp =>
|
||||
sp.GetRequiredService<RedisStreamResponseHandler>());
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
// Get the agent proxy from services
|
||||
IServiceProvider services = host.Services;
|
||||
AIAgent? agentProxy = services.GetKeyedService<AIAgent>(TravelPlannerName);
|
||||
RedisStreamResponseHandler streamHandler = services.GetRequiredService<RedisStreamResponseHandler>();
|
||||
|
||||
if (agentProxy == null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"Agent '{TravelPlannerName}' not found.");
|
||||
Console.ResetColor();
|
||||
Environment.Exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Console colors for better UX
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("=== Reliable Streaming Sample ===");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine("Enter a travel planning request (or 'exit' to quit):");
|
||||
Console.WriteLine();
|
||||
|
||||
string? lastCursor = null;
|
||||
|
||||
async Task ReadStreamTask(string conversationId, string? cursor, CancellationToken cancellationToken)
|
||||
{
|
||||
// Initialize lastCursor to the starting cursor position
|
||||
// This ensures we have a valid cursor even if cancellation happens before any chunks are processed
|
||||
lastCursor = cursor;
|
||||
|
||||
await foreach (StreamChunk chunk in streamHandler.ReadStreamAsync(conversationId, cursor, cancellationToken))
|
||||
{
|
||||
if (chunk.Error != null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.Error.WriteLine($"\n[Error: {chunk.Error}]");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.IsDone)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine();
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.Text != null)
|
||||
{
|
||||
Console.Write(chunk.Text);
|
||||
}
|
||||
|
||||
// Always update lastCursor to track the latest entry ID, even if text is null
|
||||
// This ensures we can resume from the correct position after interruption
|
||||
if (!string.IsNullOrEmpty(chunk.EntryId))
|
||||
{
|
||||
lastCursor = chunk.EntryId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New conversation: prompt from stdin
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.Write("You: ");
|
||||
Console.ResetColor();
|
||||
|
||||
string? prompt = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(prompt) || prompt.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new agent thread
|
||||
AgentThread thread = await agentProxy.GetNewThreadAsync();
|
||||
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
|
||||
string conversationId = sessionId.ToString();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"Conversation ID: {conversationId}");
|
||||
Console.WriteLine("Press [Enter] to interrupt the stream.");
|
||||
Console.ResetColor();
|
||||
|
||||
// Run the agent in the background
|
||||
DurableAgentRunOptions options = new() { IsFireAndForget = true };
|
||||
await agentProxy.RunAsync(prompt, thread, options, CancellationToken.None);
|
||||
|
||||
bool streamCompleted = false;
|
||||
while (!streamCompleted)
|
||||
{
|
||||
// On a key press, cancel the cancellation token to stop the stream
|
||||
using CancellationTokenSource userCancellationSource = new();
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
_ = Console.ReadLine();
|
||||
userCancellationSource.Cancel();
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Start reading the stream and wait for it to complete
|
||||
await ReadStreamTask(conversationId, lastCursor, userCancellationSource.Token);
|
||||
streamCompleted = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor.");
|
||||
// Ensure lastCursor is set - if it's still null, we at least have the starting cursor
|
||||
string cursorValue = lastCursor ?? "(n/a)";
|
||||
Console.WriteLine($"Last cursor: {cursorValue}");
|
||||
Console.ResetColor();
|
||||
// Explicitly flush to ensure the message is written immediately
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
if (!streamCompleted)
|
||||
{
|
||||
Console.ReadLine();
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"Resuming conversation: {conversationId} from cursor: {lastCursor ?? "(beginning)"}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("Conversation completed.");
|
||||
Console.ResetColor();
|
||||
|
||||
await host.StopAsync();
|
||||
@@ -0,0 +1,181 @@
|
||||
# Reliable Streaming with Redis
|
||||
|
||||
This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams as a message broker. It enables clients to disconnect and reconnect to ongoing agent responses without losing messages, inspired by [OpenAI's background mode](https://platform.openai.com/docs/guides/background) for the Responses API.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Reliable message delivery**: Agent responses are persisted to Redis Streams, allowing clients to resume from any point
|
||||
- **Real-time streaming**: Chunks are printed to stdout as they arrive (like `tail -f`)
|
||||
- **Cursor-based resumption**: Each chunk includes an entry ID that can be used to resume the stream
|
||||
- **Fire-and-forget agent invocation**: The agent runs in the background while the client streams from Redis
|
||||
|
||||
## 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.
|
||||
|
||||
### Additional Requirements: Redis
|
||||
|
||||
This sample requires a Redis instance. Start a local Redis instance using Docker:
|
||||
|
||||
```bash
|
||||
docker run -d --name redis -p 6379:6379 redis:latest
|
||||
```
|
||||
|
||||
To verify Redis is running:
|
||||
|
||||
```bash
|
||||
docker ps | grep redis
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
With the environment setup, you can run the sample:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/07_ReliableStreaming
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
The app will prompt you for a travel planning request:
|
||||
|
||||
```text
|
||||
=== Reliable Streaming Sample ===
|
||||
Enter a travel planning request (or 'exit' to quit):
|
||||
|
||||
You: Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around.
|
||||
```
|
||||
|
||||
The agent's response will stream to your console in real-time as chunks arrive from Redis:
|
||||
|
||||
```text
|
||||
Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
|
||||
Press [Enter] to interrupt the stream.
|
||||
|
||||
TravelPlanner: # 7-Day Tokyo Adventure
|
||||
|
||||
## Day 1: Arrival and Exploration
|
||||
...
|
||||
```
|
||||
|
||||
### Demonstrating Stream Interruption and Resumption
|
||||
|
||||
This is the key feature of reliable streaming. Follow these steps to see it in action:
|
||||
|
||||
1. **Start a stream**: Run the app and enter a travel planning request
|
||||
2. **Note the conversation ID**: The conversation ID is displayed at the start of the stream (e.g., `Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890`)
|
||||
3. **Interrupt the stream**: While the agent is still generating text, press **`Enter`** to interrupt. The agent continues running in the background - your messages are being saved to Redis.
|
||||
4. **Resume the stream**: Press **`Enter`** again to reconnect and resume the stream from the last cursor position. The app will automatically resume from where it left off.
|
||||
|
||||
```text
|
||||
Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
|
||||
Press [Enter] to interrupt the stream.
|
||||
|
||||
TravelPlanner: # 7-Day Tokyo Adventure
|
||||
|
||||
## Day 1: Arrival and Exploration
|
||||
[Streaming content...]
|
||||
|
||||
[Press Enter to interrupt]
|
||||
Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor.
|
||||
Last cursor: 1734567890123-0
|
||||
|
||||
[Press Enter to resume]
|
||||
Resuming conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 from cursor: 1734567890123-0
|
||||
|
||||
[Stream continues from where it left off...]
|
||||
```
|
||||
|
||||
## Viewing Agent State
|
||||
|
||||
You can view the state of the agent in the Durable Task Scheduler dashboard:
|
||||
|
||||
1. Open your browser and navigate to `http://localhost:8082`
|
||||
2. In the dashboard, you can see:
|
||||
- **Agents**: View the state of the TravelPlanner agent, including conversation history and current state
|
||||
- **Orchestrations**: View any orchestrations that may have been triggered by the agent
|
||||
|
||||
The conversation ID displayed in the console output (shown as "Starting new conversation: {conversationId}") corresponds to the agent's conversation thread. You can use this to identify the agent in the dashboard and inspect:
|
||||
|
||||
- The agent's conversation state
|
||||
- Tool calls made by the agent (weather and events lookups)
|
||||
- The streaming response state
|
||||
|
||||
Note that while the console app streams responses from Redis, the agent state in DTS shows the underlying durable agent execution, including all tool calls and conversation context.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```text
|
||||
┌─────────────┐ stdin (prompt) ┌─────────────────────┐
|
||||
│ Client │ ─────────────────────► │ Console App │
|
||||
│ (stdin) │ │ (Program.cs) │
|
||||
└─────────────┘ └──────────────┬──────┘
|
||||
▲ │
|
||||
│ stdout (chunks) Signal Entity
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
│ │ AgentEntity │
|
||||
│ │ (Durable Entity) │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ IAgentResponseHandler
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
│ │ RedisStreamResponse │
|
||||
│ │ Handler │
|
||||
│ └──────────┬──────────┘
|
||||
│ │
|
||||
│ XADD (write)
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────┐
|
||||
└─────────── XREAD (poll) ────────── │ Redis Streams │
|
||||
│ (Durable Log) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Client sends prompt**: The console app reads the prompt from stdin and generates a new agent thread.
|
||||
|
||||
2. **Agent invoked**: The durable agent is signaled to run the travel planner agent. This is fire-and-forget from the console app's perspective.
|
||||
|
||||
3. **Responses captured**: As the agent generates responses, the `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentRunResponseUpdate` and publishes it to a Redis Stream keyed by the agent session's conversation ID.
|
||||
|
||||
4. **Client polls Redis**: The console app streams events by polling the Redis Stream and printing chunks to stdout as they arrive.
|
||||
|
||||
5. **Resumption**: If the client interrupts the stream (e.g., by pressing Enter in the sample), it can resume from the last cursor position by providing the conversation ID and cursor to the call to resume the stream.
|
||||
|
||||
## Message Delivery Guarantees
|
||||
|
||||
This sample provides **at-least-once delivery** with the following characteristics:
|
||||
|
||||
- **Durability**: Messages are persisted to Redis Streams with configurable TTL (default: 10 minutes).
|
||||
- **Ordering**: Messages are delivered in order within a session.
|
||||
- **Real-time**: Chunks are printed as soon as they arrive from Redis.
|
||||
|
||||
### Important Considerations
|
||||
|
||||
- **No exactly-once delivery**: If a client disconnects exactly when receiving a message, it may receive that message again upon resumption. Clients should handle duplicate messages idempotently.
|
||||
- **TTL expiration**: Streams expire after the configured TTL. Clients cannot resume streams that have expired.
|
||||
- **Redis guarantees**: Redis streams are backed by Redis persistence mechanisms (RDB/AOF). Ensure your Redis instance is configured for durability as needed.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|---------------------|-------------|---------|
|
||||
| `REDIS_CONNECTION_STRING` | Redis connection string | `localhost:6379` |
|
||||
| `REDIS_STREAM_TTL_MINUTES` | How long streams are retained after last write | `10` |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` | Azure OpenAI deployment name | (required) |
|
||||
| `AZURE_OPENAI_KEY` | API key (optional, uses Azure CLI auth if not set) | (optional) |
|
||||
|
||||
## Cleanup
|
||||
|
||||
To stop and remove the Redis Docker containers:
|
||||
|
||||
```bash
|
||||
docker stop redis
|
||||
docker rm redis
|
||||
```
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace ReliableStreaming;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a chunk of data read from a Redis stream.
|
||||
/// </summary>
|
||||
/// <param name="EntryId">The Redis stream entry ID (can be used as a cursor for resumption).</param>
|
||||
/// <param name="Text">The text content of the chunk, or null if this is a completion/error marker.</param>
|
||||
/// <param name="IsDone">True if this chunk marks the end of the stream.</param>
|
||||
/// <param name="Error">An error message if something went wrong, or null otherwise.</param>
|
||||
public readonly record struct StreamChunk(string EntryId, string? Text, bool IsDone, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// An implementation of <see cref="IAgentResponseHandler"/> that publishes agent response updates
|
||||
/// to Redis Streams for reliable delivery. This enables clients to disconnect and reconnect
|
||||
/// to ongoing agent responses without losing messages.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Redis Streams provide a durable, append-only log that supports consumer groups and message
|
||||
/// acknowledgment. This implementation uses auto-generated IDs (which are timestamp-based)
|
||||
/// as sequence numbers, allowing clients to resume from any point in the stream.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each agent session gets its own Redis Stream, keyed by session ID. The stream entries
|
||||
/// contain text chunks extracted from <see cref="AgentResponseUpdate"/> objects.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RedisStreamResponseHandler : IAgentResponseHandler
|
||||
{
|
||||
private const int MaxEmptyReads = 300; // 5 minutes at 1 second intervals
|
||||
private const int PollIntervalMs = 1000;
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly TimeSpan _streamTtl;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisStreamResponseHandler" /> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="streamTtl">The time-to-live for stream entries. Streams will expire after this duration of inactivity.</param>
|
||||
public RedisStreamResponseHandler(IConnectionMultiplexer redis, TimeSpan streamTtl)
|
||||
{
|
||||
this._redis = redis;
|
||||
this._streamTtl = streamTtl;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask OnStreamingResponseUpdateAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> messageStream,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Get the current session ID from the DurableAgentContext
|
||||
// This is set by the AgentEntity before invoking the response handler
|
||||
DurableAgentContext context = DurableAgentContext.Current
|
||||
?? throw new InvalidOperationException("DurableAgentContext.Current is not set. This handler must be used within a durable agent context.");
|
||||
|
||||
// Get conversation ID from the current thread context, which is only available in the context of
|
||||
// a durable agent execution.
|
||||
string conversationId = context.CurrentThread.GetService<AgentSessionId>().ToString();
|
||||
if (string.IsNullOrEmpty(conversationId))
|
||||
{
|
||||
throw new InvalidOperationException("Unable to determine conversation ID from the current thread.");
|
||||
}
|
||||
|
||||
string streamKey = GetStreamKey(conversationId);
|
||||
|
||||
IDatabase db = this._redis.GetDatabase();
|
||||
int sequenceNumber = 0;
|
||||
|
||||
await foreach (AgentResponseUpdate update in messageStream.WithCancellation(cancellationToken))
|
||||
{
|
||||
// Extract just the text content - this avoids serialization round-trip issues
|
||||
string text = update.Text;
|
||||
|
||||
// Only publish non-empty text chunks
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
// Create the stream entry with the text and metadata
|
||||
NameValueEntry[] entries =
|
||||
[
|
||||
new NameValueEntry("text", text),
|
||||
new NameValueEntry("sequence", sequenceNumber++),
|
||||
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
|
||||
];
|
||||
|
||||
// Add to the Redis Stream with auto-generated ID (timestamp-based)
|
||||
await db.StreamAddAsync(streamKey, entries);
|
||||
|
||||
// Refresh the TTL on each write to keep the stream alive during active streaming
|
||||
await db.KeyExpireAsync(streamKey, this._streamTtl);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a sentinel entry to mark the end of the stream
|
||||
NameValueEntry[] endEntries =
|
||||
[
|
||||
new NameValueEntry("text", ""),
|
||||
new NameValueEntry("sequence", sequenceNumber),
|
||||
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
|
||||
new NameValueEntry("done", "true"),
|
||||
];
|
||||
await db.StreamAddAsync(streamKey, endEntries);
|
||||
|
||||
// Set final TTL - the stream will be cleaned up after this duration
|
||||
await db.KeyExpireAsync(streamKey, this._streamTtl);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask OnAgentResponseAsync(AgentResponse message, CancellationToken cancellationToken)
|
||||
{
|
||||
// This handler is optimized for streaming responses.
|
||||
// For non-streaming responses, we don't need to store in Redis since
|
||||
// the response is returned directly to the caller.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads chunks from a Redis stream for the given session, yielding them as they become available.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The conversation ID to read from.</param>
|
||||
/// <param name="cursor">Optional cursor to resume from. If null, reads from the beginning.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of stream chunks.</returns>
|
||||
public async IAsyncEnumerable<StreamChunk> ReadStreamAsync(
|
||||
string conversationId,
|
||||
string? cursor,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
string streamKey = GetStreamKey(conversationId);
|
||||
|
||||
IDatabase db = this._redis.GetDatabase();
|
||||
string startId = string.IsNullOrEmpty(cursor) ? "0-0" : cursor;
|
||||
|
||||
int emptyReadCount = 0;
|
||||
bool hasSeenData = false;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
StreamEntry[]? entries = null;
|
||||
string? errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
entries = await db.StreamReadAsync(streamKey, startId, count: 100);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
|
||||
if (errorMessage != null)
|
||||
{
|
||||
yield return new StreamChunk(startId, null, false, errorMessage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
// entries is guaranteed to be non-null if errorMessage is null
|
||||
if (entries!.Length == 0)
|
||||
{
|
||||
if (!hasSeenData)
|
||||
{
|
||||
emptyReadCount++;
|
||||
if (emptyReadCount >= MaxEmptyReads)
|
||||
{
|
||||
yield return new StreamChunk(
|
||||
startId,
|
||||
null,
|
||||
false,
|
||||
$"Stream not found or timed out after {MaxEmptyReads * PollIntervalMs / 1000} seconds");
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(PollIntervalMs, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasSeenData = true;
|
||||
|
||||
foreach (StreamEntry entry in entries)
|
||||
{
|
||||
startId = entry.Id.ToString();
|
||||
string? text = entry["text"];
|
||||
string? done = entry["done"];
|
||||
|
||||
if (done == "true")
|
||||
{
|
||||
yield return new StreamChunk(startId, null, true, null);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
yield return new StreamChunk(startId, text, false, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exited the loop due to cancellation, throw to signal the caller
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Redis Stream key for a given conversation ID.
|
||||
/// </summary>
|
||||
/// <param name="conversationId">The conversation ID.</param>
|
||||
/// <returns>The Redis Stream key.</returns>
|
||||
internal static string GetStreamKey(string conversationId) => $"agent-stream:{conversationId}";
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# Console App Samples
|
||||
|
||||
This directory contains samples for console app hosting of durable agents. These samples use standard I/O (stdin/stdout) for interaction, making them both interactive and scriptable.
|
||||
|
||||
- **[01_SingleAgent](01_SingleAgent)**: A sample that demonstrates how to host a single conversational agent in a console app and interact with it via stdin/stdout.
|
||||
- **[02_AgentOrchestration_Chaining](02_AgentOrchestration_Chaining)**: A sample that demonstrates how to host a single conversational agent in a console app and invoke it using a durable orchestration.
|
||||
- **[03_AgentOrchestration_Concurrency](03_AgentOrchestration_Concurrency)**: A sample that demonstrates how to host multiple agents in a console app and run them concurrently using a durable orchestration.
|
||||
- **[04_AgentOrchestration_Conditionals](04_AgentOrchestration_Conditionals)**: A sample that demonstrates how to host multiple agents in a console app and run them sequentially using a durable orchestration with conditionals.
|
||||
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
|
||||
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
|
||||
- **[07_ReliableStreaming](07_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
|
||||
|
||||
## Running the Samples
|
||||
|
||||
These samples are designed to be run locally in a cloned repository.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
The following prerequisites are required to run the samples:
|
||||
|
||||
- [.NET 10.0 SDK or later](https://dotnet.microsoft.com/download/dotnet)
|
||||
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated (`az login`) or an API key for the Azure OpenAI service
|
||||
- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-4o-mini or better is recommended)
|
||||
- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted)
|
||||
- [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally
|
||||
- [Redis](https://redis.io/) (for sample 07 only) - can be run locally using Docker
|
||||
|
||||
### Configuring RBAC Permissions for Azure OpenAI
|
||||
|
||||
These samples are configured to use the Azure OpenAI service with RBAC permissions to access the model. You'll need to configure the RBAC permissions for the Azure OpenAI service to allow the console app to access the model.
|
||||
|
||||
Below is an example of how to configure the RBAC permissions for the Azure OpenAI service to allow the current user to access the model.
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
az role assignment create \
|
||||
--assignee "yourname@contoso.com" \
|
||||
--role "Cognitive Services OpenAI User" \
|
||||
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
az role assignment create `
|
||||
--assignee "yourname@contoso.com" `
|
||||
--role "Cognitive Services OpenAI User" `
|
||||
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
|
||||
```
|
||||
|
||||
More information on how to configure RBAC permissions for Azure OpenAI can be found in the [Azure OpenAI documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=cli).
|
||||
|
||||
### Setting an API key for the Azure OpenAI service
|
||||
|
||||
As an alternative to configuring Azure RBAC permissions, you can set an API key for the Azure OpenAI service by setting the `AZURE_OPENAI_KEY` environment variable.
|
||||
|
||||
Bash (Linux/macOS/WSL):
|
||||
|
||||
```bash
|
||||
export AZURE_OPENAI_KEY="your-api-key"
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_KEY="your-api-key"
|
||||
```
|
||||
|
||||
### Start Durable Task Scheduler
|
||||
|
||||
Most samples use the Durable Task Scheduler (DTS) to support hosted agents and durable orchestrations. DTS also allows you to view the status of orchestrations and their inputs and outputs from a web UI.
|
||||
|
||||
To run the Durable Task Scheduler locally, you can use the following `docker` command:
|
||||
|
||||
```bash
|
||||
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
|
||||
```
|
||||
|
||||
The DTS dashboard will be available at `http://localhost:8080`.
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Each sample reads configuration from environment variables. You'll need to set the following environment variables:
|
||||
|
||||
```bash
|
||||
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
export AZURE_OPENAI_DEPLOYMENT="your-deployment-name"
|
||||
```
|
||||
|
||||
### Running the Console Apps
|
||||
|
||||
Navigate to the sample directory and run the console app:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/DurableAgents/ConsoleApps/01_SingleAgent
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The `--framework` option is required to specify the target framework for the console app because the samples are designed to support multiple target frameworks. If you are using a different target framework, you can specify it with the `--framework` option.
|
||||
|
||||
The app will prompt you for input via stdin.
|
||||
|
||||
### Viewing the sample output
|
||||
|
||||
The console app output is displayed directly in the terminal where you ran `dotnet run`. Agent responses are printed to stdout with subtle color coding for better readability.
|
||||
|
||||
You can also see the state of agents and orchestrations in the Durable Task Scheduler dashboard at `http://localhost:8082`.
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="../Directory.Build.props" />
|
||||
|
||||
<!-- Remove the Environment alias from parent Directory.Build.props to allow System.Environment usage -->
|
||||
<ItemGroup>
|
||||
<Using Remove="SampleHelpers.SampleEnvironment" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -26,14 +26,14 @@ var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: J
|
||||
// agentVersion.Version = <versionNumber>,
|
||||
// agentVersion.Name = <agentName>
|
||||
|
||||
// You can retrieve an AIAgent for an already created server side agent version.
|
||||
// You can use an AIAgent with an already created server side agent version.
|
||||
AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion);
|
||||
|
||||
// You can also create another AIAgent version by providing the same name with a different definition.
|
||||
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
|
||||
// You can also get the AIAgent latest version just providing its name.
|
||||
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
|
||||
AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName);
|
||||
var latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
|
||||
// The AIAgent version can be accessed via the GetService method.
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Bot.ObjectModel" />
|
||||
<PackageReference Include="Microsoft.Bot.ObjectModel.Json" />
|
||||
<PackageReference Include="Microsoft.Bot.ObjectModel.PowerFx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to inject additional AI context into a ChatClientAgent using a custom AIContextProvider component that is attached to the agent.
|
||||
// The sample also shows how to combine the results from multiple providers into a single class, in order to attach multiple of these to an agent.
|
||||
// This mechanism can be used for various purposes, such as injecting RAG search results or memories into the agent's context.
|
||||
// Also note that Agent Framework already provides built-in AIContextProviders for many of these scenarios.
|
||||
|
||||
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
using SampleApp;
|
||||
using MEAI = Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5-mini";
|
||||
|
||||
// A sample function to load the next three calendar events for the user.
|
||||
Func<Task<string[]>> loadNextThreeCalendarEvents = async () =>
|
||||
{
|
||||
// In a real implementation, this method would connect to a calendar service
|
||||
return new string[]
|
||||
{
|
||||
"Doctor's appointment today at 15:00",
|
||||
"Team meeting today at 17:00",
|
||||
"Birthday party today at 20:00"
|
||||
};
|
||||
};
|
||||
|
||||
// Create an agent with an AI context provider attached that aggregates two other providers:
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new AzureCliCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = """
|
||||
You are a helpful personal assistant.
|
||||
You manage a TODO list for the user. When the user has completed one of the tasks it can be removed from the TODO list. Only provide the list of TODO items if asked.
|
||||
You remind users of upcoming calendar events when the user interacts with you.
|
||||
""" },
|
||||
ChatMessageStoreFactory = (ctx, ct) => new ValueTask<ChatMessageStore>(new InMemoryChatMessageStore()
|
||||
// Use WithAIContextProviderMessageRemoval, so that we don't store the messages from the AI context provider in the chat history.
|
||||
// You may want to store these messages, depending on their content and your requirements.
|
||||
.WithAIContextProviderMessageRemoval()),
|
||||
// Add an AI context provider that maintains a todo list for the agent and one that provides upcoming calendar entries.
|
||||
// Wrap these in an AI context provider that aggregates the other two.
|
||||
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new AggregatingAIContextProvider([
|
||||
AggregatingAIContextProvider.CreateFactory((jsonElement, jsonSerializerOptions) => new TodoListAIContextProvider(jsonElement, jsonSerializerOptions)),
|
||||
AggregatingAIContextProvider.CreateFactory((_, _) => new CalendarSearchAIContextProvider(loadNextThreeCalendarEvents))
|
||||
], ctx.SerializedState, ctx.JsonSerializerOptions)),
|
||||
});
|
||||
|
||||
// Invoke the agent and output the text result.
|
||||
AgentThread thread = await agent.GetNewThreadAsync();
|
||||
Console.WriteLine(await agent.RunAsync("I need to pick up milk from the supermarket.", thread) + "\n");
|
||||
Console.WriteLine(await agent.RunAsync("I need to take Sally for soccer practice.", thread) + "\n");
|
||||
Console.WriteLine(await agent.RunAsync("I need to make a dentist appointment for Jimmy.", thread) + "\n");
|
||||
Console.WriteLine(await agent.RunAsync("I've taken Sally to soccer practice.", thread) + "\n");
|
||||
|
||||
// We can serialize the thread, and it will contain both the chat history and the data that each AI context provider serialized.
|
||||
JsonElement serializedThread = thread.Serialize();
|
||||
// Let's print it to console to show the contents.
|
||||
Console.WriteLine(JsonSerializer.Serialize(serializedThread, options: new JsonSerializerOptions() { WriteIndented = true, IndentSize = 2 }) + "\n");
|
||||
// The serialized thread can be stored long term in a persistent store, but in this case we will just deserialize again and continue the conversation.
|
||||
thread = await agent.DeserializeThreadAsync(serializedThread);
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Considering my appointments, can you create a plan for my day that plans out when I should complete the items on my todo list?", thread) + "\n");
|
||||
|
||||
namespace SampleApp
|
||||
{
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/>, which maintains a todo list for the agent.
|
||||
/// </summary>
|
||||
internal sealed class TodoListAIContextProvider : AIContextProvider
|
||||
{
|
||||
private readonly List<string> _todoItems = new();
|
||||
|
||||
public TodoListAIContextProvider(JsonElement jsonElement, JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
// Only try and restore the state if we got an array, since any other json would be invalid or undefined/null meaning
|
||||
// it's the first time we are running.
|
||||
if (jsonElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
this._todoItems = JsonSerializer.Deserialize<List<string>>(jsonElement.GetRawText(), jsonSerializerOptions) ?? new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
StringBuilder outputMessageBuilder = new();
|
||||
outputMessageBuilder.AppendLine("Your todo list contains the following items:");
|
||||
|
||||
if (this._todoItems.Count == 0)
|
||||
{
|
||||
outputMessageBuilder.AppendLine(" (no items)");
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < this._todoItems.Count; i++)
|
||||
{
|
||||
outputMessageBuilder.AppendLine($"{i}. {this._todoItems[i]}");
|
||||
}
|
||||
}
|
||||
|
||||
return new ValueTask<AIContext>(new AIContext
|
||||
{
|
||||
Tools = [AIFunctionFactory.Create(this.AddTodoItem), AIFunctionFactory.Create(this.RemoveTodoItem)],
|
||||
Messages = [new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString())]
|
||||
});
|
||||
}
|
||||
|
||||
[Description("Adds an item to the todo list. Index is zero based.")]
|
||||
private void RemoveTodoItem(int index) =>
|
||||
this._todoItems.RemoveAt(index);
|
||||
|
||||
private void AddTodoItem(string item) =>
|
||||
this._todoItems.Add(string.IsNullOrWhiteSpace(item) ? throw new ArgumentException("Item must have a value") : item);
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) =>
|
||||
JsonSerializer.SerializeToElement(this._todoItems, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> which searches for upcoming calendar events and adds them to the AI context.
|
||||
/// </summary>
|
||||
internal sealed class CalendarSearchAIContextProvider(Func<Task<string[]>> loadNextThreeCalendarEvents) : AIContextProvider
|
||||
{
|
||||
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var events = await loadNextThreeCalendarEvents();
|
||||
|
||||
StringBuilder outputMessageBuilder = new();
|
||||
outputMessageBuilder.AppendLine("You have the following upcoming calendar events:");
|
||||
foreach (var calendarEvent in events)
|
||||
{
|
||||
outputMessageBuilder.AppendLine($" - {calendarEvent}");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Messages =
|
||||
[
|
||||
new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString()),
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> which aggregates multiple AI context providers into one.
|
||||
/// Serialized state for the different providers are stored under their type name.
|
||||
/// Tools and messages from all providers are combined, and instructions are concatenated.
|
||||
/// </summary>
|
||||
internal sealed class AggregatingAIContextProvider : AIContextProvider
|
||||
{
|
||||
private readonly List<AIContextProvider> _providers = new();
|
||||
|
||||
public AggregatingAIContextProvider(ProviderFactory[] providerFactories, JsonElement jsonElement, JsonSerializerOptions? jsonSerializerOptions)
|
||||
{
|
||||
// We received a json object, so let's check if it has some previously serialized state that we can use.
|
||||
if (jsonElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
this._providers = providerFactories
|
||||
.Select(factory => factory.FactoryMethod(jsonElement.TryGetProperty(factory.ProviderType.Name, out var prop) ? prop : default, jsonSerializerOptions))
|
||||
.ToList();
|
||||
return;
|
||||
}
|
||||
|
||||
// We didn't receive any valid json, so we can just construct fresh providers.
|
||||
this._providers = providerFactories
|
||||
.Select(factory => factory.FactoryMethod(default, jsonSerializerOptions))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Invoke all the sub providers.
|
||||
var tasks = this._providers.Select(provider => provider.InvokingAsync(context, cancellationToken).AsTask());
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Combine the results from each sub provider.
|
||||
return new AIContext
|
||||
{
|
||||
Tools = results.SelectMany(r => r.Tools ?? []).ToList(),
|
||||
Messages = results.SelectMany(r => r.Messages ?? []).ToList(),
|
||||
Instructions = string.Join("\n", results.Select(r => r.Instructions).Where(s => !string.IsNullOrEmpty(s)))
|
||||
};
|
||||
}
|
||||
|
||||
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
Dictionary<string, JsonElement> elements = new();
|
||||
foreach (var provider in this._providers)
|
||||
{
|
||||
JsonElement element = provider.Serialize(jsonSerializerOptions);
|
||||
|
||||
// Don't try to store state for any providers that aren't producing any.
|
||||
if (element.ValueKind != JsonValueKind.Undefined && element.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
elements[provider.GetType().Name] = element;
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.SerializeToElement(elements, jsonSerializerOptions);
|
||||
}
|
||||
|
||||
public static ProviderFactory CreateFactory<TProviderType>(Func<JsonElement, JsonSerializerOptions?, TProviderType> factoryMethod)
|
||||
where TProviderType : AIContextProvider => new()
|
||||
{
|
||||
FactoryMethod = (jsonElement, jsonSerializerOptions) => factoryMethod(jsonElement, jsonSerializerOptions),
|
||||
ProviderType = typeof(TProviderType)
|
||||
};
|
||||
|
||||
public readonly struct ProviderFactory
|
||||
{
|
||||
public Func<JsonElement, JsonSerializerOptions?, AIContextProvider> FactoryMethod { get; init; }
|
||||
|
||||
public Type ProviderType { get; init; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|
||||
|[Deep research with an agent](./Agent_Step18_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|
||||
|[Declarative agent](./Agent_Step19_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step20_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+4
-4
@@ -28,14 +28,14 @@ AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(age
|
||||
// agentVersion.Version = <versionNumber>,
|
||||
// agentVersion.Name = <agentName>
|
||||
|
||||
// You can retrieve an AIAgent for an already created server side agent version.
|
||||
AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion);
|
||||
// You can use an AIAgent with an already created server side agent version.
|
||||
AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion);
|
||||
|
||||
// You can also create another AIAgent version by providing the same name with a different definition/instruction.
|
||||
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
|
||||
|
||||
// You can also get the AIAgent latest version by just providing its name.
|
||||
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
|
||||
AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName);
|
||||
AgentVersion latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
|
||||
|
||||
// The AIAgent version can be accessed via the GetService method.
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@ AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deplo
|
||||
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
|
||||
AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
|
||||
|
||||
// You can retrieve an AIAgent for a already created server side agent version.
|
||||
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
|
||||
// You can use an AIAgent with an already created server side agent version.
|
||||
AIAgent jokerAgent = aiProjectClient.AsAIAgent(agentVersion);
|
||||
|
||||
// Invoke the agent with streaming support.
|
||||
await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate."))
|
||||
|
||||
+13
-6
@@ -19,19 +19,23 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions });
|
||||
|
||||
// Create a server side agent version with the Azure.AI.Agents SDK client.
|
||||
AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
|
||||
|
||||
// Retrieve an AIAgent for the created server side agent version.
|
||||
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
|
||||
ChatClientAgent jokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, options);
|
||||
|
||||
// Invoke the agent with a multi-turn conversation, where the context is preserved in the thread object.
|
||||
AgentThread thread = await jokerAgent.GetNewThreadAsync();
|
||||
// Create a conversation in the server
|
||||
ProjectConversationsClient conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient();
|
||||
ProjectConversation conversation = await conversationsClient.CreateProjectConversationAsync();
|
||||
|
||||
// Providing the conversation Id is not strictly necessary, but by not providing it no information will show up in the Foundry Project UI as conversations.
|
||||
// Threads that doesn't have a conversation Id will work based on the `PreviousResponseId`.
|
||||
AgentThread thread = await jokerAgent.GetNewThreadAsync(conversation.Id);
|
||||
|
||||
Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread));
|
||||
Console.WriteLine(await jokerAgent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", thread));
|
||||
|
||||
// Invoke the agent with a multi-turn conversation and streaming, where the context is preserved in the thread object.
|
||||
thread = await jokerAgent.GetNewThreadAsync();
|
||||
thread = await jokerAgent.GetNewThreadAsync(conversation.Id);
|
||||
await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread))
|
||||
{
|
||||
Console.WriteLine(update);
|
||||
@@ -43,3 +47,6 @@ await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Now a
|
||||
|
||||
// Cleanup by agent name removes the agent version created.
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name);
|
||||
|
||||
// Cleanup the conversation created.
|
||||
await conversationsClient.DeleteConversationAsync(conversation.Id);
|
||||
|
||||
+18
-9
@@ -1,14 +1,15 @@
|
||||
# Multi-turn Conversation with AI Agents
|
||||
|
||||
This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads.
|
||||
This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads and conversation IDs.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- Creating an AI agent with instructions
|
||||
- Using threads to maintain conversation context
|
||||
- Creating a project conversation to track conversations in the Foundry UI
|
||||
- Using threads with conversation IDs to maintain conversation context
|
||||
- Running multi-turn conversations with text output
|
||||
- Running multi-turn conversations with streaming output
|
||||
- Managing agent lifecycle (creation and deletion)
|
||||
- Managing agent and conversation lifecycle (creation and deletion)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -41,10 +42,18 @@ dotnet run --project .\FoundryAgents_Step02_MultiturnConversation
|
||||
The sample will:
|
||||
|
||||
1. Create an agent named "JokerAgent" with instructions to tell jokes
|
||||
2. Create a thread for conversation context
|
||||
3. Run the agent with a text prompt and display the response
|
||||
4. Send a follow-up message to the same thread, demonstrating context preservation
|
||||
5. Create a new thread and run the agent with streaming
|
||||
6. Send a follow-up streaming message to demonstrate multi-turn streaming
|
||||
7. Clean up resources by deleting the agent
|
||||
2. Create a project conversation to enable visibility in the Azure Foundry UI
|
||||
3. Create a thread linked to the conversation ID for context tracking
|
||||
4. Run the agent with a text prompt and display the response
|
||||
5. Send a follow-up message to the same thread, demonstrating context preservation
|
||||
6. Create a new thread sharing the same conversation ID and run the agent with streaming
|
||||
7. Send a follow-up streaming message to demonstrate multi-turn streaming
|
||||
8. Clean up resources by deleting the agent and conversation
|
||||
|
||||
## Conversation ID vs PreviousResponseId
|
||||
|
||||
When working with multi-turn conversations, there are two approaches:
|
||||
|
||||
- **With Conversation ID**: By passing a `conversation.Id` to `GetNewThreadAsync()`, the conversation will be visible in the Azure Foundry Project UI. This is useful for tracking and debugging conversations.
|
||||
- **Without Conversation ID**: Threads created without a conversation ID still work correctly, maintaining context via `PreviousResponseId`. However, these conversations may not appear in the Foundry UI.
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ Console.WriteLine($"Age: {response.Result.Age}");
|
||||
Console.WriteLine($"Occupation: {response.Result.Occupation}");
|
||||
|
||||
// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce.
|
||||
ChatClientAgent agentWithPersonInfo = aiProjectClient.CreateAIAgent(
|
||||
ChatClientAgent agentWithPersonInfo = await aiProjectClient.CreateAIAgentAsync(
|
||||
model: deploymentName,
|
||||
new ChatClientAgentOptions()
|
||||
{
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ using var tracerProvider = tracerProviderBuilder.Build();
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AIAgent agent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions)
|
||||
AIAgent agent = (await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions))
|
||||
.AsBuilder()
|
||||
.UseOpenTelemetry(sourceName: sourceName)
|
||||
.Build();
|
||||
|
||||
+16
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
// This sample shows how to use dependency injection to register an AIAgent and use it from a hosted service with a user input chat loop.
|
||||
|
||||
using System.ClientModel;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -14,16 +15,27 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJEC
|
||||
const string JokerInstructions = "You are good at telling jokes.";
|
||||
const string JokerName = "JokerAgent";
|
||||
|
||||
AIProjectClient aIProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Create a new agent if one doesn't exist already.
|
||||
ChatClientAgent agent;
|
||||
try
|
||||
{
|
||||
agent = await aIProjectClient.GetAIAgentAsync(name: JokerName);
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
agent = await aIProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions);
|
||||
}
|
||||
|
||||
// Create a host builder that we will register services with and then run.
|
||||
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Add the agents client to the service collection.
|
||||
builder.Services.AddSingleton((sp) => new AIProjectClient(new Uri(endpoint), new AzureCliCredential()));
|
||||
builder.Services.AddSingleton((sp) => aIProjectClient);
|
||||
|
||||
// Add the AI agent to the service collection.
|
||||
builder.Services.AddSingleton<AIAgent>((sp)
|
||||
=> sp.GetRequiredService<AIProjectClient>()
|
||||
.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions));
|
||||
builder.Services.AddSingleton<AIAgent>((sp) => agent);
|
||||
|
||||
// Add a sample service that will use the agent to respond to user input.
|
||||
builder.Services.AddHostedService<SampleService>();
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
|
||||
Console.WriteLine($"Creating the agent '{agentName}' ...");
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AIAgent agent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: agentName,
|
||||
model: deploymentName,
|
||||
instructions: "You answer questions related to GitHub repositories only.",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const string VisionName = "VisionAgent";
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AIAgent agent = aiProjectClient.CreateAIAgent(name: VisionName, model: deploymentName, instructions: VisionInstructions);
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model: deploymentName, instructions: VisionInstructions);
|
||||
|
||||
ChatMessage message = new(ChatRole.User, [
|
||||
new TextContent("What do you see in this image?"),
|
||||
|
||||
+2
-2
@@ -25,14 +25,14 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
|
||||
|
||||
// Create the weather agent with function tools.
|
||||
AITool weatherTool = AIFunctionFactory.Create(GetWeather);
|
||||
AIAgent weatherAgent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent weatherAgent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: WeatherName,
|
||||
model: deploymentName,
|
||||
instructions: WeatherInstructions,
|
||||
tools: [weatherTool]);
|
||||
|
||||
// Create the main agent, and provide the weather agent as a function tool.
|
||||
AIAgent agent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: MainName,
|
||||
model: deploymentName,
|
||||
instructions: MainInstructions,
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ AITool dateTimeTool = AIFunctionFactory.Create(GetDateTime, name: nameof(GetDate
|
||||
AITool getWeatherTool = AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather));
|
||||
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AIAgent originalAgent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent originalAgent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: AssistantName,
|
||||
model: deploymentName,
|
||||
instructions: AssistantInstructions,
|
||||
@@ -69,7 +69,7 @@ Console.WriteLine($"Function calling response: {functionCallResponse}");
|
||||
// Special per-request middleware agent.
|
||||
Console.WriteLine("\n\n=== Example 4: Middleware with human in the loop function approval ===");
|
||||
|
||||
AIAgent humanInTheLoopAgent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent humanInTheLoopAgent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: "HumanInTheLoopAgent",
|
||||
model: deploymentName,
|
||||
instructions: "You are an Human in the loop testing AI assistant that helps people find information.",
|
||||
|
||||
@@ -34,7 +34,7 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
|
||||
|
||||
// Define the agent with plugin tools
|
||||
// Define the agent you want to create. (Prompt Agent in this case)
|
||||
AIAgent agent = aiProjectClient.CreateAIAgent(
|
||||
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
|
||||
name: AssistantName,
|
||||
model: deploymentName,
|
||||
instructions: AssistantInstructions,
|
||||
|
||||
@@ -15,6 +15,17 @@ For more information about the previous classic agents and for what's new in Fou
|
||||
|
||||
For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md).
|
||||
|
||||
## Agent Versioning and Static Definitions
|
||||
|
||||
One of the key architectural changes in the new Foundry Agents compared to the classic experience is how agent definitions are handled. In the new architecture, agents have **versions** and their definitions are established at creation time. This means that the agent's configuration—including instructions, tools, and options—is fixed when the agent version is created.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Agent versions are static and strictly adhere to their original definition. Any attempt to provide or override tools, instructions, or options during an agent run or request will be ignored by the agent, as the API does not support runtime configuration changes. All agent behavior must be defined at agent creation time.
|
||||
|
||||
This design ensures consistency and predictability in agent behavior across all interactions with a specific agent version.
|
||||
|
||||
The Agent Framework intentionally ignores unsupported runtime parameters rather than throwing exceptions. This abstraction-first approach ensures that code written against the unified agent abstraction remains portable across providers (OpenAI, Azure OpenAI, Foundry Agents). It removes the need for provider-specific conditional logic. Teams can adopt Foundry Agents without rewriting existing orchestration code. Configurations that work with other providers will gracefully degrade, rather than fail, when the underlying API does not support them.
|
||||
|
||||
## Getting started with Foundry Agents prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
@@ -45,7 +45,7 @@ internal sealed class Program
|
||||
|
||||
string workflowInput = GetWorkflowInput(args);
|
||||
|
||||
AIAgent agent = aiProjectClient.GetAIAgent(agentVersion);
|
||||
AIAgent agent = aiProjectClient.AsAIAgent(agentVersion);
|
||||
|
||||
AgentThread thread = await agent.GetNewThreadAsync();
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@ namespace Microsoft.Agents.AI;
|
||||
/// and process user requests. An agent instance may participate in multiple concurrent conversations, and each conversation
|
||||
/// may involve multiple agents working together.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{DisplayName,nq}")]
|
||||
[DebuggerDisplay("{DebuggerDisplay,nq}")]
|
||||
public abstract class AIAgent
|
||||
{
|
||||
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
|
||||
private string DebuggerDisplay =>
|
||||
this.Name is { } name ? $"Id = {this.Id}, Name = {name}" : $"Id = {this.Id}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this agent instance.
|
||||
/// </summary>
|
||||
|
||||
-188
@@ -82,39 +82,6 @@ public static class PersistentAgentsClientExtensions
|
||||
}, services: services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> for the persistent agent.</returns>
|
||||
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
|
||||
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this PersistentAgentsClient persistentAgentsClient,
|
||||
string agentId,
|
||||
ChatOptions? chatOptions = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (persistentAgentsClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(persistentAgentsClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
|
||||
}
|
||||
|
||||
var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken);
|
||||
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, chatOptions, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
@@ -232,45 +199,6 @@ public static class PersistentAgentsClientExtensions
|
||||
return new ChatClientAgent(chatClient, agentOptions, services: services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
|
||||
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
|
||||
/// <param name="options">Full set of options to configure the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentId"/> is empty or whitespace.</exception>
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this PersistentAgentsClient persistentAgentsClient,
|
||||
string agentId,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (persistentAgentsClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(persistentAgentsClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
|
||||
}
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken);
|
||||
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, options, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
@@ -366,122 +294,6 @@ public static class PersistentAgentsClientExtensions
|
||||
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
|
||||
/// <param name="model">The model to be used by the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="instructions">The instructions for the agent.</param>
|
||||
/// <param name="tools">The tools to be used by the agent.</param>
|
||||
/// <param name="toolResources">The resources for the tools.</param>
|
||||
/// <param name="temperature">The temperature setting for the agent.</param>
|
||||
/// <param name="topP">The top-p setting for the agent.</param>
|
||||
/// <param name="responseFormat">The response format for the agent.</param>
|
||||
/// <param name="metadata">The metadata for the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this PersistentAgentsClient persistentAgentsClient,
|
||||
string model,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
string? instructions = null,
|
||||
IEnumerable<ToolDefinition>? tools = null,
|
||||
ToolResources? toolResources = null,
|
||||
float? temperature = null,
|
||||
float? topP = null,
|
||||
BinaryData? responseFormat = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (persistentAgentsClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(persistentAgentsClient));
|
||||
}
|
||||
|
||||
var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent(
|
||||
model: model,
|
||||
name: name,
|
||||
description: description,
|
||||
instructions: instructions,
|
||||
tools: tools,
|
||||
toolResources: toolResources,
|
||||
temperature: temperature,
|
||||
topP: topP,
|
||||
responseFormat: responseFormat,
|
||||
metadata: metadata,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// Get a local proxy for the agent to work with.
|
||||
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
|
||||
/// <param name="model">The model to be used by the agent.</param>
|
||||
/// <param name="options">Full set of options to configure the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this PersistentAgentsClient persistentAgentsClient,
|
||||
string model,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (persistentAgentsClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(persistentAgentsClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model));
|
||||
}
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools);
|
||||
|
||||
var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent(
|
||||
model: model,
|
||||
name: options.Name,
|
||||
description: options.Description,
|
||||
instructions: options.ChatOptions?.Instructions,
|
||||
tools: toolDefinitionsAndResources.ToolDefinitions,
|
||||
toolResources: toolDefinitionsAndResources.ToolResources,
|
||||
temperature: null,
|
||||
topP: null,
|
||||
responseFormat: null,
|
||||
metadata: null,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count))
|
||||
{
|
||||
options = options.Clone();
|
||||
options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools;
|
||||
}
|
||||
|
||||
// Get a local proxy for the agent to work with.
|
||||
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
|
||||
/// </summary>
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace Azure.AI.Projects;
|
||||
public static partial class AzureAIProjectChatClientExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to create the <see cref="ChatClientAgent"/> with. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="agentReference">The <see cref="AgentReference"/> representing the name and version of the server side agent to create a <see cref="ChatClientAgent"/> for. Cannot be <see langword="null"/>.</param>
|
||||
@@ -38,10 +38,10 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="agentReference"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
|
||||
/// <remarks>
|
||||
/// When retrieving an agent by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
|
||||
/// When instantiating a <see cref="ChatClientAgent"/> by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
|
||||
/// on <see cref="AIAgent.GetService(Type, object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
|
||||
/// </remarks>
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
public static ChatClientAgent AsAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
AgentReference agentReference,
|
||||
IList<AITool>? tools = null,
|
||||
@@ -52,7 +52,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
Throw.IfNull(agentReference);
|
||||
ThrowIfInvalidAgentName(agentReference.Name);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentReference,
|
||||
new ChatClientAgentOptions()
|
||||
@@ -65,40 +65,6 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to create the <see cref="ChatClientAgent"/> with. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name of the server side agent to create a <see cref="ChatClientAgent"/> for. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
/// <param name="tools">The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations based on the latest version of the named Azure AI Agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty or whitespace, or when the agent with the specified name was not found.</exception>
|
||||
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string name,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
ThrowIfInvalidAgentName(name);
|
||||
|
||||
AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, name, cancellationToken);
|
||||
|
||||
return AsAIAgent(
|
||||
aiProjectClient,
|
||||
agentRecord,
|
||||
tools,
|
||||
clientFactory,
|
||||
services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
@@ -134,7 +100,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a runnable agent instance from the provided agent record.
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentRecord"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to interact with Azure AI Agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="agentRecord">The agent record to be converted. The latest version will be used. Cannot be <see langword="null"/>.</param>
|
||||
@@ -155,7 +121,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
var allowDeclarativeMode = tools is not { Count: > 0 };
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentRecord,
|
||||
tools,
|
||||
@@ -165,7 +131,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a runnable agent instance from a <see cref="AgentVersion"/> containing metadata about an Azure AI Agent.
|
||||
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentVersion"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to interact with Azure AI Agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="agentVersion">The agent version to be converted. Cannot be <see langword="null"/>.</param>
|
||||
@@ -186,7 +152,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
var allowDeclarativeMode = tools is not { Count: > 0 };
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
tools,
|
||||
@@ -196,47 +162,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to cancel the operation if needed.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(options);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Name))
|
||||
{
|
||||
throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options));
|
||||
}
|
||||
|
||||
ThrowIfInvalidAgentName(options.Name);
|
||||
|
||||
AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, options.Name, cancellationToken);
|
||||
var agentVersion = agentRecord.Versions.Latest;
|
||||
|
||||
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
agentOptions,
|
||||
clientFactory,
|
||||
services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
|
||||
/// Asynchronously retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
|
||||
@@ -267,7 +193,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
agentOptions,
|
||||
@@ -276,49 +202,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI agent using the specified configuration parameters.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name for the agent.</param>
|
||||
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
/// <param name="instructions">The instructions that guide the agent's behavior. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
/// <param name="description">The description for the agent.</param>
|
||||
/// <param name="tools">The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools.</param>
|
||||
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/>, <paramref name="model"/>, or <paramref name="instructions"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> or <paramref name="instructions"/> is empty or whitespace.</exception>
|
||||
/// <remarks>When using prompt agent definitions with tools the parameter <paramref name="tools"/> needs to be provided.</remarks>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string name,
|
||||
string model,
|
||||
string instructions,
|
||||
string? description = null,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
ThrowIfInvalidAgentName(name);
|
||||
Throw.IfNullOrWhitespace(model);
|
||||
Throw.IfNullOrWhitespace(instructions);
|
||||
|
||||
return CreateAIAgent(
|
||||
aiProjectClient,
|
||||
name,
|
||||
tools,
|
||||
new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description },
|
||||
clientFactory,
|
||||
services,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI agent using the specified configuration parameters.
|
||||
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name for the agent.</param>
|
||||
@@ -360,73 +244,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to cancel the operation if needed.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace, or when the agent name is not provided in the options.</exception>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string model,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
Throw.IfNull(options);
|
||||
Throw.IfNullOrWhitespace(model);
|
||||
const bool RequireInvocableTools = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Name))
|
||||
{
|
||||
throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options));
|
||||
}
|
||||
|
||||
ThrowIfInvalidAgentName(options.Name);
|
||||
|
||||
PromptAgentDefinition agentDefinition = new(model)
|
||||
{
|
||||
Instructions = options.ChatOptions?.Instructions,
|
||||
Temperature = options.ChatOptions?.Temperature,
|
||||
TopP = options.ChatOptions?.TopP,
|
||||
TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) }
|
||||
};
|
||||
|
||||
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
|
||||
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
|
||||
{
|
||||
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
|
||||
}
|
||||
|
||||
ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools);
|
||||
|
||||
AgentVersionCreationOptions? creationOptions = new(agentDefinition);
|
||||
if (!string.IsNullOrWhiteSpace(options.Description))
|
||||
{
|
||||
creationOptions.Description = options.Description;
|
||||
}
|
||||
|
||||
AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, options.Name, creationOptions, cancellationToken);
|
||||
|
||||
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
agentOptions,
|
||||
clientFactory,
|
||||
services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
|
||||
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
|
||||
@@ -483,7 +301,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
agentOptions,
|
||||
@@ -492,42 +310,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new AI agent using the specified agent definition and optional configuration parameters.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
/// <param name="name">The name for the agent.</param>
|
||||
/// <param name="creationOptions">Settings that control the creation of the agent.</param>
|
||||
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="creationOptions"/> is <see langword="null"/>.</exception>
|
||||
/// <remarks>
|
||||
/// When using this extension method with a <see cref="PromptAgentDefinition"/> the tools are only declarative and not invocable.
|
||||
/// Invocation of any in-process tools will need to be handled manually.
|
||||
/// </remarks>
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string name,
|
||||
AgentVersionCreationOptions creationOptions,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Throw.IfNull(aiProjectClient);
|
||||
ThrowIfInvalidAgentName(name);
|
||||
Throw.IfNull(creationOptions);
|
||||
|
||||
return CreateAIAgent(
|
||||
aiProjectClient,
|
||||
name,
|
||||
tools: null,
|
||||
creationOptions,
|
||||
clientFactory,
|
||||
services: null,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously creates a new AI agent using the specified agent definition and optional configuration
|
||||
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
|
||||
/// parameters.
|
||||
/// </summary>
|
||||
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
|
||||
@@ -566,18 +349,6 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W");
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an agent record by name using the Protocol method with user-agent header.
|
||||
/// </summary>
|
||||
private static AgentRecord GetAgentRecordByName(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken)
|
||||
{
|
||||
ClientResult protocolResponse = aiProjectClient.Agents.GetAgent(agentName, cancellationToken.ToRequestOptions(false));
|
||||
var rawResponse = protocolResponse.GetRawResponse();
|
||||
AgentRecord? result = ModelReaderWriter.Read<AgentRecord>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
|
||||
return ClientResult.FromOptionalValue(result, rawResponse).Value!
|
||||
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header.
|
||||
/// </summary>
|
||||
@@ -590,19 +361,6 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an agent version using the Protocol method with user-agent header.
|
||||
/// </summary>
|
||||
private static AgentVersion CreateAgentVersionWithProtocol(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default));
|
||||
ClientResult protocolResponse = aiProjectClient.Agents.CreateAgentVersion(agentName, protocolRequest, cancellationToken.ToRequestOptions(false));
|
||||
|
||||
var rawResponse = protocolResponse.GetRawResponse();
|
||||
AgentVersion? result = ModelReaderWriter.Read<AgentVersion>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
|
||||
return ClientResult.FromValue(result, rawResponse).Value!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously creates an agent version using the Protocol method with user-agent header.
|
||||
/// </summary>
|
||||
@@ -616,33 +374,6 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
return ClientResult.FromValue(result, rawResponse).Value!;
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateAIAgent(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string name,
|
||||
IList<AITool>? tools,
|
||||
AgentVersionCreationOptions creationOptions,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
IServiceProvider? services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allowDeclarativeMode = tools is not { Count: > 0 };
|
||||
|
||||
if (!allowDeclarativeMode)
|
||||
{
|
||||
ApplyToolsToAgentDefinition(creationOptions.Definition, tools);
|
||||
}
|
||||
|
||||
AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, name, creationOptions, cancellationToken);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
tools,
|
||||
clientFactory,
|
||||
!allowDeclarativeMode,
|
||||
services);
|
||||
}
|
||||
|
||||
private static async Task<ChatClientAgent> CreateAIAgentAsync(
|
||||
this AIProjectClient aiProjectClient,
|
||||
string name,
|
||||
@@ -661,7 +392,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
|
||||
AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return CreateChatClientAgent(
|
||||
return AsChatClientAgent(
|
||||
aiProjectClient,
|
||||
agentVersion,
|
||||
tools,
|
||||
@@ -671,7 +402,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
|
||||
private static ChatClientAgent CreateChatClientAgent(
|
||||
private static ChatClientAgent AsChatClientAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
AgentVersion agentVersion,
|
||||
ChatClientAgentOptions agentOptions,
|
||||
@@ -689,7 +420,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
|
||||
private static ChatClientAgent CreateChatClientAgent(
|
||||
private static ChatClientAgent AsChatClientAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
AgentRecord agentRecord,
|
||||
ChatClientAgentOptions agentOptions,
|
||||
@@ -707,7 +438,7 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
|
||||
private static ChatClientAgent CreateChatClientAgent(
|
||||
private static ChatClientAgent AsChatClientAgent(
|
||||
AIProjectClient aiProjectClient,
|
||||
AgentReference agentReference,
|
||||
ChatClientAgentOptions agentOptions,
|
||||
@@ -725,14 +456,14 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
}
|
||||
|
||||
/// <summary>This method creates an <see cref="ChatClientAgent"/> with a auto-generated ChatClientAgentOptions from the specified configuration parameters.</summary>
|
||||
private static ChatClientAgent CreateChatClientAgent(
|
||||
private static ChatClientAgent AsChatClientAgent(
|
||||
AIProjectClient AIProjectClient,
|
||||
AgentVersion agentVersion,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
bool requireInvocableTools,
|
||||
IServiceProvider? services)
|
||||
=> CreateChatClientAgent(
|
||||
=> AsChatClientAgent(
|
||||
AIProjectClient,
|
||||
agentVersion,
|
||||
CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools),
|
||||
@@ -740,14 +471,14 @@ public static partial class AzureAIProjectChatClientExtensions
|
||||
services);
|
||||
|
||||
/// <summary>This method creates an <see cref="ChatClientAgent"/> with a auto-generated ChatClientAgentOptions from the specified configuration parameters.</summary>
|
||||
private static ChatClientAgent CreateChatClientAgent(
|
||||
private static ChatClientAgent AsChatClientAgent(
|
||||
AIProjectClient AIProjectClient,
|
||||
AgentRecord agentRecord,
|
||||
IList<AITool>? tools,
|
||||
Func<IChatClient, IChatClient>? clientFactory,
|
||||
bool requireInvocableTools,
|
||||
IServiceProvider? services)
|
||||
=> CreateChatClientAgent(
|
||||
=> AsChatClientAgent(
|
||||
AIProjectClient,
|
||||
agentRecord,
|
||||
CreateChatClientAgentOptions(agentRecord.Versions.Latest, new ChatOptions() { Tools = tools }, requireInvocableTools),
|
||||
|
||||
@@ -26,13 +26,6 @@ public readonly struct AgentSessionId : IEquatable<AgentSessionId>
|
||||
this._entityId = new EntityInstanceId(ToEntityName(name), key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an agent name to its underlying entity name representation.
|
||||
/// </summary>
|
||||
/// <param name="name">The agent name.</param>
|
||||
/// <returns>The entity name used by Durable Task for this agent.</returns>
|
||||
public static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the agent that owns the session. Names are case-insensitive.
|
||||
/// </summary>
|
||||
@@ -43,6 +36,17 @@ public readonly struct AgentSessionId : IEquatable<AgentSessionId>
|
||||
/// </summary>
|
||||
public string Key => this._entityId.Key;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an agent name to its underlying entity name representation.
|
||||
/// </summary>
|
||||
/// <param name="name">The agent name.</param>
|
||||
/// <returns>The entity name used by Durable Task for this agent.</returns>
|
||||
internal static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
|
||||
|
||||
/// <summary>
|
||||
/// Converts the <see cref="AgentSessionId"/> to an <see cref="EntityInstanceId"/>.
|
||||
/// </summary>
|
||||
/// <returns>The <see cref="EntityInstanceId"/> representation of the <see cref="AgentSessionId"/>.</returns>
|
||||
internal EntityInstanceId ToEntityId() => this._entityId;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -93,39 +93,6 @@ public static class OpenAIAssistantClientExtensions
|
||||
}, services: services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
|
||||
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
|
||||
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
|
||||
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this AssistantClient assistantClient,
|
||||
string agentId,
|
||||
ChatOptions? chatOptions = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (assistantClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(assistantClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
|
||||
}
|
||||
|
||||
var assistant = assistantClient.GetAssistant(agentId, cancellationToken);
|
||||
return assistantClient.AsAIAgent(assistant, chatOptions, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
|
||||
/// </summary>
|
||||
@@ -245,46 +212,6 @@ public static class OpenAIAssistantClientExtensions
|
||||
return new ChatClientAgent(chatClient, mergedOptions, services: services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
|
||||
/// </summary>
|
||||
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
|
||||
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
|
||||
/// <param name="options">Full set of options to configure the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
|
||||
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="assistantClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException"><paramref name="agentId"/> is empty or whitespace.</exception>
|
||||
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
|
||||
public static ChatClientAgent GetAIAgent(
|
||||
this AssistantClient assistantClient,
|
||||
string agentId,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
IServiceProvider? services = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (assistantClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(assistantClient));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
{
|
||||
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
|
||||
}
|
||||
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
var assistant = assistantClient.GetAssistant(agentId, cancellationToken);
|
||||
return assistantClient.AsAIAgent(assistant, options, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
|
||||
/// </summary>
|
||||
@@ -325,111 +252,6 @@ public static class OpenAIAssistantClientExtensions
|
||||
return assistantClient.AsAIAgent(assistantResponse, options, clientFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
|
||||
/// </summary>
|
||||
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
|
||||
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
|
||||
/// <param name="instructions">Optional system instructions that define the agent's behavior and personality.</param>
|
||||
/// <param name="name">Optional name for the agent for identification purposes.</param>
|
||||
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
|
||||
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Assistant service.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
|
||||
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this AssistantClient client,
|
||||
string model,
|
||||
string? instructions = null,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IList<AITool>? tools = null,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null) =>
|
||||
client.CreateAIAgent(
|
||||
model,
|
||||
new ChatClientAgentOptions()
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions()
|
||||
{
|
||||
Tools = tools,
|
||||
Instructions = instructions
|
||||
}
|
||||
},
|
||||
clientFactory,
|
||||
loggerFactory,
|
||||
services);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
|
||||
/// </summary>
|
||||
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
|
||||
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
|
||||
/// <param name="options">Full set of options to configure the agent.</param>
|
||||
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
|
||||
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
|
||||
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Assistant service.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
|
||||
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
|
||||
public static ChatClientAgent CreateAIAgent(
|
||||
this AssistantClient client,
|
||||
string model,
|
||||
ChatClientAgentOptions options,
|
||||
Func<IChatClient, IChatClient>? clientFactory = null,
|
||||
ILoggerFactory? loggerFactory = null,
|
||||
IServiceProvider? services = null)
|
||||
{
|
||||
Throw.IfNull(client);
|
||||
Throw.IfNullOrEmpty(model);
|
||||
Throw.IfNull(options);
|
||||
|
||||
var assistantOptions = new AssistantCreationOptions()
|
||||
{
|
||||
Name = options.Name,
|
||||
Description = options.Description,
|
||||
Instructions = options.ChatOptions?.Instructions,
|
||||
};
|
||||
|
||||
// Convert AITools to ToolDefinitions and ToolResources
|
||||
var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools);
|
||||
if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 })
|
||||
{
|
||||
toolDefinitionsAndResources.ToolDefinitions.ForEach(x => assistantOptions.Tools.Add(x));
|
||||
}
|
||||
|
||||
if (toolDefinitionsAndResources.ToolResources is not null)
|
||||
{
|
||||
assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources;
|
||||
}
|
||||
|
||||
// Create the assistant in the assistant service.
|
||||
var assistantCreateResult = client.CreateAssistant(model, assistantOptions);
|
||||
var assistantId = assistantCreateResult.Value.Id;
|
||||
|
||||
// Build the local agent object.
|
||||
var chatClient = client.AsIChatClient(assistantId);
|
||||
if (clientFactory is not null)
|
||||
{
|
||||
chatClient = clientFactory(chatClient);
|
||||
}
|
||||
|
||||
var agentOptions = options.Clone();
|
||||
agentOptions.Id = assistantId;
|
||||
options.ChatOptions ??= new ChatOptions();
|
||||
options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools;
|
||||
|
||||
return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
|
||||
/// </summary>
|
||||
|
||||
@@ -51,6 +51,15 @@ public class Workflow
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the collection of executor bindings, keyed by their ID.
|
||||
/// </summary>
|
||||
/// <returns>A copy of the executor bindings dictionary. Modifications do not affect the workflow.</returns>
|
||||
public Dictionary<string, ExecutorBinding> ReflectExecutors()
|
||||
{
|
||||
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the identifier of the starting executor of the workflow.
|
||||
/// </summary>
|
||||
|
||||
@@ -73,7 +73,12 @@ public static partial class AIAgentExtensions
|
||||
[Description("Input query to invoke the agent.")] string query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await agent.RunAsync(query, thread: thread, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// Propagate any additional properties from the parent agent's run to the child agent if the parent is using a FunctionInvokingChatClient.
|
||||
AgentRunOptions? agentRunOptions = FunctionInvokingChatClient.CurrentContext?.Options?.AdditionalProperties is AdditionalPropertiesDictionary dict
|
||||
? new AgentRunOptions { AdditionalProperties = dict }
|
||||
: null;
|
||||
|
||||
var response = await agent.RunAsync(query, thread: thread, options: agentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,9 +22,7 @@ public class AIProjectClientCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -43,20 +41,9 @@ public class AIProjectClientCreateTests
|
||||
Description = AgentDescription,
|
||||
ChatOptions = new() { Instructions = AgentInstructions }
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
|
||||
model: s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
Name = AgentName,
|
||||
Description = AgentDescription,
|
||||
ChatOptions = new() { Instructions = AgentInstructions }
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
name: AgentName,
|
||||
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }),
|
||||
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
|
||||
name: AgentName,
|
||||
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -84,9 +71,7 @@ public class AIProjectClientCreateTests
|
||||
|
||||
[Theory(Skip = "For manual testing only")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -120,21 +105,11 @@ public class AIProjectClientCreateTests
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
|
||||
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -157,9 +132,7 @@ public class AIProjectClientCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -192,22 +165,12 @@ public class AIProjectClientCreateTests
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
|
||||
// Foundry (definitions + resources provided directly)
|
||||
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
|
||||
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
|
||||
model: s_config.DeploymentName,
|
||||
name: AgentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -229,7 +192,6 @@ public class AIProjectClientCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -248,13 +210,6 @@ public class AIProjectClientCreateTests
|
||||
Name = AgentName,
|
||||
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
Name = AgentName,
|
||||
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
|
||||
}),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
|
||||
-60
@@ -20,9 +20,7 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -41,24 +39,11 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
Name = AgentName,
|
||||
Description = AgentDescription
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = AgentInstructions },
|
||||
Name = AgentName,
|
||||
Description = AgentDescription
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
name: AgentName,
|
||||
description: AgentDescription),
|
||||
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
name: AgentName,
|
||||
description: AgentDescription),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -85,9 +70,7 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
|
||||
[Theory(Skip = "For manual testing only")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -125,26 +108,11 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new FileSearchToolDefinition()],
|
||||
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
|
||||
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new FileSearchToolDefinition()],
|
||||
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -167,9 +135,7 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
[InlineData("CreateWithFoundryOptionsAsync")]
|
||||
[InlineData("CreateWithFoundryOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -205,26 +171,11 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
|
||||
}
|
||||
}),
|
||||
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new CodeInterpreterToolDefinition()],
|
||||
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
|
||||
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
instructions: AgentInstructions,
|
||||
tools: [new CodeInterpreterToolDefinition()],
|
||||
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
@@ -246,7 +197,6 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
|
||||
[Theory]
|
||||
[InlineData("CreateWithChatClientAgentOptionsAsync")]
|
||||
[InlineData("CreateWithChatClientAgentOptionsSync")]
|
||||
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
|
||||
{
|
||||
// Arrange.
|
||||
@@ -267,16 +217,6 @@ public class AzureAIAgentsPersistentCreateTests
|
||||
Tools = [weatherFunction]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
|
||||
s_config.DeploymentName,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = AgentInstructions,
|
||||
Tools = [weatherFunction]
|
||||
}
|
||||
}),
|
||||
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
|
||||
};
|
||||
|
||||
|
||||
+10
-10
@@ -42,9 +42,9 @@ public class AgentResponseUpdateExtensionsTests
|
||||
{
|
||||
AgentResponseUpdate[] updates =
|
||||
[
|
||||
new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" },
|
||||
new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" },
|
||||
new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } },
|
||||
new(null, "world!") { CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
|
||||
new(null, "world!") { CreatedAt = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
|
||||
|
||||
new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] },
|
||||
new() { Contents = [new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 })] },
|
||||
@@ -62,7 +62,7 @@ public class AgentResponseUpdateExtensionsTests
|
||||
Assert.Equal(7, response.Usage.OutputTokenCount);
|
||||
|
||||
Assert.Equal("someResponse", response.ResponseId);
|
||||
Assert.Equal(new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
|
||||
Assert.Equal(new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
|
||||
|
||||
Assert.Equal(2, response.Messages.Count);
|
||||
|
||||
@@ -226,13 +226,13 @@ public class AgentResponseUpdateExtensionsTests
|
||||
// Unix epoch (as "null") should not overwrite
|
||||
new(null, "b") { CreatedAt = unixEpoch },
|
||||
|
||||
// Newer timestamp should overwrite
|
||||
// Newer timestamp should not overwrite (first timestamp wins)
|
||||
new(null, "c") { CreatedAt = middle },
|
||||
|
||||
// Older timestamp should not overwrite
|
||||
new(null, "d") { CreatedAt = early },
|
||||
|
||||
// Even newer timestamp should overwrite
|
||||
// Even newer timestamp should not overwrite (first timestamp wins)
|
||||
new(null, "e") { CreatedAt = late },
|
||||
|
||||
// Unix epoch should not overwrite again
|
||||
@@ -249,20 +249,20 @@ public class AgentResponseUpdateExtensionsTests
|
||||
|
||||
Assert.Equal("abcdefg", response.Messages[0].Text);
|
||||
Assert.Equal(ChatRole.Tool, response.Messages[0].Role);
|
||||
Assert.Equal(late, response.Messages[0].CreatedAt);
|
||||
Assert.Equal(late, response.CreatedAt);
|
||||
Assert.Equal(early, response.Messages[0].CreatedAt);
|
||||
Assert.Equal(early, response.CreatedAt);
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> ToAgentResponse_TimestampFolding_MemberData()
|
||||
{
|
||||
// Base test cases
|
||||
// Base test cases - first non-null valid timestamp wins
|
||||
var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[]
|
||||
{
|
||||
(null, null, null),
|
||||
("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"),
|
||||
(null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T11:00:00Z"),
|
||||
("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"),
|
||||
("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z"), // First timestamp wins
|
||||
("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), // First timestamp wins
|
||||
("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
|
||||
};
|
||||
|
||||
+8
-307
@@ -18,44 +18,6 @@ namespace Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.Extensions;
|
||||
|
||||
public sealed class PersistentAgentsClientExtensionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((PersistentAgentsClient)null!).GetAIAgent("test-agent"));
|
||||
|
||||
Assert.Equal("persistentAgentsClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentException when agentId is null or whitespace.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullOrWhitespaceAgentId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var mockClient = new Mock<PersistentAgentsClient>();
|
||||
|
||||
// Act & Assert - null agentId
|
||||
var exception1 = Assert.Throws<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgent(null!));
|
||||
Assert.Equal("agentId", exception1.ParamName);
|
||||
|
||||
// Act & Assert - empty agentId
|
||||
var exception2 = Assert.Throws<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgent(""));
|
||||
Assert.Equal("agentId", exception2.ParamName);
|
||||
|
||||
// Act & Assert - whitespace agentId
|
||||
var exception3 = Assert.Throws<ArgumentException>(() =>
|
||||
mockClient.Object.GetAIAgent(" "));
|
||||
Assert.Equal("agentId", exception3.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
@@ -94,19 +56,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("agentId", exception3.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((PersistentAgentsClient)null!).CreateAIAgent("test-model"));
|
||||
|
||||
Assert.Equal("persistentAgentsClient", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
@@ -124,14 +73,14 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
/// Verify that GetAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly()
|
||||
public async Task GetAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = client.GetAIAgent(
|
||||
var agent = await client.GetAIAgentAsync(
|
||||
agentId: "test-agent-id",
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
@@ -146,13 +95,13 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
/// Verify that GetAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithoutClientFactory_WorksNormally()
|
||||
public async Task GetAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = client.GetAIAgent(agentId: "test-agent-id");
|
||||
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -164,13 +113,13 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
/// Verify that GetAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithNullClientFactory_WorksNormally()
|
||||
public async Task GetAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
PersistentAgentsClient client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = client.GetAIAgent(agentId: "test-agent-id", clientFactory: null);
|
||||
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id", clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -178,29 +127,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(
|
||||
model: "test-model",
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
@@ -223,42 +149,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(model: "test-model");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(model: "test-model", clientFactory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.Null(retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
@@ -372,33 +262,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("Original Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with agentId and options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
const string AgentId = "agent_abc123";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Override Name",
|
||||
Description = "Override Description",
|
||||
ChatOptions = new() { Instructions = "Override Instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.GetAIAgent(AgentId, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Override Name", agent.Name);
|
||||
Assert.Equal("Override Description", agent.Description);
|
||||
Assert.Equal("Override Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with agentId and options works correctly.
|
||||
/// </summary>
|
||||
@@ -509,23 +372,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentException when agentId is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithOptionsAndEmptyAgentId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
client.GetAIAgent(string.Empty, options));
|
||||
|
||||
Assert.Equal("agentId", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty.
|
||||
/// </summary>
|
||||
@@ -543,33 +389,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("agentId", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
const string Model = "test-model";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent",
|
||||
Description = "Test description",
|
||||
ChatOptions = new() { Instructions = "Test instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(Model, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
Assert.Equal("Test description", agent.Description);
|
||||
Assert.Equal("Test instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with options works correctly.
|
||||
/// </summary>
|
||||
@@ -597,38 +416,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("Test instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with options and clientFactory applies the factory correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
TestChatClient? testChatClient = null;
|
||||
const string Model = "test-model";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Test Agent"
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(
|
||||
Model,
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Test Agent", agent.Name);
|
||||
|
||||
// Verify that the custom chat client can be retrieved from the agent's service collection
|
||||
var retrievedTestClient = agent.GetService<TestChatClient>();
|
||||
Assert.NotNull(retrievedTestClient);
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with options and clientFactory applies the factory correctly.
|
||||
/// </summary>
|
||||
@@ -661,22 +448,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Same(testChatClient, retrievedTestClient);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
client.CreateAIAgent("test-model", (ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
@@ -693,23 +464,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent throws ArgumentException when model is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithEmptyModel_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
client.CreateAIAgent(string.Empty, options));
|
||||
|
||||
Assert.Equal("model", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync throws ArgumentException when model is empty.
|
||||
/// </summary>
|
||||
@@ -727,35 +481,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Equal("model", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithServices_PassesServicesToAgent()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var serviceProvider = new TestServiceProvider();
|
||||
const string Model = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(
|
||||
Model,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(functionInvokingClient);
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that CreateAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
@@ -785,30 +510,6 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithServices_PassesServicesToAgent()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
var serviceProvider = new TestServiceProvider();
|
||||
|
||||
// Act
|
||||
var agent = client.GetAIAgent("agent_abc123", services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
|
||||
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
|
||||
var chatClient = agent.GetService<IChatClient>();
|
||||
Assert.NotNull(chatClient);
|
||||
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
|
||||
Assert.NotNull(functionInvokingClient);
|
||||
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
@@ -837,7 +538,7 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with both clientFactory and services works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly()
|
||||
public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var client = CreateFakePersistentAgentsClient();
|
||||
@@ -846,7 +547,7 @@ public sealed class PersistentAgentsClientExtensionsTests
|
||||
const string Model = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = client.CreateAIAgent(
|
||||
var agent = await client.CreateAIAgentAsync(
|
||||
Model,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
|
||||
+140
-853
File diff suppressed because it is too large
Load Diff
+960
@@ -0,0 +1,960 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
|
||||
|
||||
[Collection("Samples")]
|
||||
[Trait("Category", "SampleValidation")]
|
||||
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
|
||||
{
|
||||
private const string DtsPort = "8080";
|
||||
private const string RedisPort = "6379";
|
||||
|
||||
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
|
||||
private static readonly IConfiguration s_configuration =
|
||||
new ConfigurationBuilder()
|
||||
.AddUserSecrets(Assembly.GetExecutingAssembly())
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
private static bool s_infrastructureStarted;
|
||||
private static readonly string s_samplesPath = Path.GetFullPath(
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "DurableAgents", "ConsoleApps"));
|
||||
|
||||
private readonly ITestOutputHelper _outputHelper = outputHelper;
|
||||
|
||||
async Task IAsyncLifetime.InitializeAsync()
|
||||
{
|
||||
if (!s_infrastructureStarted)
|
||||
{
|
||||
await this.StartSharedInfrastructureAsync();
|
||||
s_infrastructureStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
async Task IAsyncLifetime.DisposeAsync()
|
||||
{
|
||||
// Nothing to clean up
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
string agentResponse = string.Empty;
|
||||
bool inputSent = false;
|
||||
|
||||
// Read output from logs queue
|
||||
string? line;
|
||||
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||
{
|
||||
// Look for the agent's response. Unlike the interactive mode, we won't actually see a line
|
||||
// that starts with "Joker: ". Instead, we'll see a line that looks like "You: Joker: ..." because
|
||||
// the standard input is *not* echoed back to standard output.
|
||||
if (line.Contains("Joker: ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// This will give us the first line of the agent's response, which is all we need to verify that the agent is working.
|
||||
agentResponse = line.Substring("Joker: ".Length).Trim();
|
||||
break;
|
||||
}
|
||||
else if (!inputSent)
|
||||
{
|
||||
// Send input to stdin after we've started seeing output from the app
|
||||
await this.WriteInputAsync(process, "Tell me a joke about a pirate.", testTimeoutCts.Token);
|
||||
inputSent = true;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(inputSent, "Input was not sent to the agent");
|
||||
Assert.NotEmpty(agentResponse);
|
||||
|
||||
// Send exit command
|
||||
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||
string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
// Console app runs automatically, just wait for completion
|
||||
string? line;
|
||||
bool foundSuccess = false;
|
||||
|
||||
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||
{
|
||||
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundSuccess = true;
|
||||
}
|
||||
|
||||
if (line.Contains("Result:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string result = line.Substring("Result:".Length).Trim();
|
||||
Assert.NotEmpty(result);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for failure
|
||||
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Fail("Orchestration failed.");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiAgentConcurrencySampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
// Send input to stdin
|
||||
await this.WriteInputAsync(process, "What is temperature?", testTimeoutCts.Token);
|
||||
|
||||
// Read output from logs queue
|
||||
StringBuilder output = new();
|
||||
string? line;
|
||||
bool foundSuccess = false;
|
||||
bool foundPhysicist = false;
|
||||
bool foundChemist = false;
|
||||
|
||||
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||
{
|
||||
output.AppendLine(line);
|
||||
|
||||
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundSuccess = true;
|
||||
}
|
||||
|
||||
if (line.Contains("Physicist's response:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundPhysicist = true;
|
||||
}
|
||||
|
||||
if (line.Contains("Chemist's response:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundChemist = true;
|
||||
}
|
||||
|
||||
// Check for failure
|
||||
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Fail("Orchestration failed.");
|
||||
}
|
||||
|
||||
// Stop reading once we have both responses
|
||||
if (foundSuccess && foundPhysicist && foundChemist)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
|
||||
Assert.True(foundPhysicist, "Physicist response not found.");
|
||||
Assert.True(foundChemist, "Chemist response not found.");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiAgentConditionalSampleValidationAsync()
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
// Test with legitimate email
|
||||
await this.TestSpamDetectionAsync(
|
||||
process: process,
|
||||
logs: logs,
|
||||
emailId: "email-001",
|
||||
emailContent: "Hi John. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!",
|
||||
expectedSpam: false,
|
||||
testTimeoutCts.Token);
|
||||
|
||||
// Restart the process for the second test
|
||||
await process.WaitForExitAsync();
|
||||
});
|
||||
|
||||
// Run second test with spam email
|
||||
using CancellationTokenSource testTimeoutCts2 = this.CreateTestTimeoutCts();
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
await this.TestSpamDetectionAsync(
|
||||
process,
|
||||
logs,
|
||||
emailId: "email-002",
|
||||
emailContent: "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
|
||||
expectedSpam: true,
|
||||
testTimeoutCts2.Token);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task TestSpamDetectionAsync(
|
||||
Process process,
|
||||
BlockingCollection<OutputLog> logs,
|
||||
string emailId,
|
||||
string emailContent,
|
||||
bool expectedSpam,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Send email content to stdin
|
||||
await this.WriteInputAsync(process, emailContent, cancellationToken);
|
||||
|
||||
// Read output from logs queue
|
||||
string? line;
|
||||
bool foundSuccess = false;
|
||||
|
||||
while ((line = this.ReadLogLine(logs, cancellationToken)) != null)
|
||||
{
|
||||
if (line.Contains("Email sent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.False(expectedSpam, "Email was sent, but was expected to be marked as spam.");
|
||||
}
|
||||
|
||||
if (line.Contains("Email marked as spam", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.True(expectedSpam, "Email was marked as spam, but was expected to be sent.");
|
||||
}
|
||||
|
||||
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundSuccess = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for failure
|
||||
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Fail("Orchestration failed.");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
|
||||
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
|
||||
|
||||
// Start the HITL orchestration following the happy path from README
|
||||
await this.WriteInputAsync(process, "The Future of Artificial Intelligence", testTimeoutCts.Token);
|
||||
await this.WriteInputAsync(process, "3", testTimeoutCts.Token);
|
||||
await this.WriteInputAsync(process, "72", testTimeoutCts.Token);
|
||||
|
||||
// Read output from logs queue
|
||||
string? line;
|
||||
bool rejectionSent = false;
|
||||
bool approvalSent = false;
|
||||
bool contentPublished = false;
|
||||
|
||||
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||
{
|
||||
// Look for notification that content is ready. The first time we see this, we should send a rejection.
|
||||
// The second time we see this, we should send approval.
|
||||
if (line.Contains("Content is ready for review", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!rejectionSent)
|
||||
{
|
||||
// Prompt: Approve? (y/n):
|
||||
await this.WriteInputAsync(process, "n", testTimeoutCts.Token);
|
||||
|
||||
// Prompt: Feedback (optional):
|
||||
await this.WriteInputAsync(
|
||||
process,
|
||||
"The article needs more technical depth and better examples. Rewrite it with less than 300 words.",
|
||||
testTimeoutCts.Token);
|
||||
rejectionSent = true;
|
||||
}
|
||||
else if (!approvalSent)
|
||||
{
|
||||
// Prompt: Approve? (y/n):
|
||||
await this.WriteInputAsync(process, "y", testTimeoutCts.Token);
|
||||
|
||||
// Prompt: Feedback (optional):
|
||||
await this.WriteInputAsync(process, "Looks good!", testTimeoutCts.Token);
|
||||
approvalSent = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This should never happen
|
||||
Assert.Fail("Unexpected message found.");
|
||||
}
|
||||
}
|
||||
|
||||
// Look for success message
|
||||
if (line.Contains("PUBLISHING: Content has been published", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentPublished = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for failure
|
||||
if (line.Contains("Orchestration failed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Fail("Orchestration failed.");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
|
||||
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
|
||||
Assert.True(contentPublished, "Content was not published.");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LongRunningToolsSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
|
||||
|
||||
// Test starting an agent that schedules a content generation orchestration
|
||||
await this.WriteInputAsync(
|
||||
process,
|
||||
"Start a content generation workflow for the topic 'The Future of Artificial Intelligence'. Keep it less than 300 words.",
|
||||
testTimeoutCts.Token);
|
||||
|
||||
// Read output from logs queue
|
||||
bool rejectionSent = false;
|
||||
bool approvalSent = false;
|
||||
bool contentPublished = false;
|
||||
|
||||
string? line;
|
||||
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
|
||||
{
|
||||
// Look for notification that content is ready. The first time we see this, we should send a rejection.
|
||||
// The second time we see this, we should send approval.
|
||||
if (line.Contains("NOTIFICATION: Please review the following content for approval", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wait for the notification to be fully written to the console
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
|
||||
|
||||
if (!rejectionSent)
|
||||
{
|
||||
// Reject the content with feedback. Note that we need to send a newline character to the console first before sending the input.
|
||||
await this.WriteInputAsync(
|
||||
process,
|
||||
"\nReject the content with feedback: Make it even shorter.",
|
||||
testTimeoutCts.Token);
|
||||
rejectionSent = true;
|
||||
}
|
||||
else if (!approvalSent)
|
||||
{
|
||||
// Approve the content. Note that we need to send a newline character to the console first before sending the input.
|
||||
await this.WriteInputAsync(
|
||||
process,
|
||||
"\nApprove the content",
|
||||
testTimeoutCts.Token);
|
||||
approvalSent = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// This should never happen
|
||||
Assert.Fail("Unexpected message found.");
|
||||
}
|
||||
}
|
||||
|
||||
// Look for success message
|
||||
if (line.Contains("PUBLISHING: Content has been published successfully", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contentPublished = true;
|
||||
|
||||
// Ask for the status of the workflow to confirm that it completed successfully.
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
|
||||
await this.WriteInputAsync(process, "\nGet the status of the workflow you previously started", testTimeoutCts.Token);
|
||||
}
|
||||
|
||||
// Check for workflow completion or failure
|
||||
if (contentPublished)
|
||||
{
|
||||
if (line.Contains("Completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
else if (line.Contains("Failed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Assert.Fail("Workflow failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
|
||||
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
|
||||
Assert.True(contentPublished, "Content was not published.");
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReliableStreamingSampleValidationAsync()
|
||||
{
|
||||
string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming");
|
||||
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
|
||||
{
|
||||
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
|
||||
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
|
||||
|
||||
// Test the agent endpoint with a simple prompt
|
||||
await this.WriteInputAsync(process, "Plan a 5-day trip to Seattle. Include daily activities.", testTimeoutCts.Token);
|
||||
|
||||
// Read output from stdout - should stream in real-time
|
||||
// NOTE: The sample uses Console.Write() for streaming chunks, which means content may not be line-buffered.
|
||||
// We test the interrupt/resume flow by:
|
||||
// 1. Waiting for at least 10 lines of content
|
||||
// 2. Sending Enter to interrupt
|
||||
// 3. Verifying we get "Last cursor" output
|
||||
// 4. Sending Enter again to resume
|
||||
// 5. Verifying we get more content and that we're not restarting from the beginning
|
||||
string? line;
|
||||
bool foundConversationStart = false;
|
||||
int contentLinesBeforeInterrupt = 0;
|
||||
int contentLinesAfterResume = 0;
|
||||
bool foundLastCursor = false;
|
||||
bool foundResumeMessage = false;
|
||||
bool interrupted = false;
|
||||
bool resumed = false;
|
||||
|
||||
// Read output with a reasonable timeout
|
||||
using CancellationTokenSource readTimeoutCts = this.CreateTestTimeoutCts();
|
||||
DateTime? interruptTime = null;
|
||||
try
|
||||
{
|
||||
while ((line = this.ReadLogLine(logs, readTimeoutCts.Token)) != null)
|
||||
{
|
||||
// Look for the conversation start message (updated format)
|
||||
if (line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundConversationStart = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a content line (not prompts or status messages)
|
||||
bool isContentLine = !string.IsNullOrWhiteSpace(line) &&
|
||||
!line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("Press [Enter]", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("You:", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("exit", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("Stream cancelled", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase) &&
|
||||
!line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Phase 1: Collect content before interrupt
|
||||
if (foundConversationStart && !interrupted && isContentLine)
|
||||
{
|
||||
contentLinesBeforeInterrupt++;
|
||||
}
|
||||
|
||||
// Phase 2: Wait for enough content, then interrupt
|
||||
// Interrupt after 2 lines to maximize chance of catching stream while active
|
||||
// (streams can complete very quickly, so we need to interrupt early)
|
||||
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
|
||||
interrupted = true;
|
||||
interruptTime = DateTime.Now;
|
||||
|
||||
// Send Enter to interrupt the stream
|
||||
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
||||
|
||||
// Give the cancellation token a moment to be processed
|
||||
// Use a longer delay to ensure cancellation propagates
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), testTimeoutCts.Token);
|
||||
}
|
||||
|
||||
// Phase 3: Look for "Last cursor" message after interrupt
|
||||
if (interrupted && !resumed && line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundLastCursor = true;
|
||||
|
||||
// Send Enter again to resume
|
||||
this._outputHelper.WriteLine("Resuming stream from last cursor");
|
||||
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
||||
resumed = true;
|
||||
}
|
||||
|
||||
// Phase 4: Look for resume message
|
||||
if (resumed && line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundResumeMessage = true;
|
||||
}
|
||||
|
||||
// Phase 5: Collect content after resume
|
||||
if (resumed && isContentLine)
|
||||
{
|
||||
contentLinesAfterResume++;
|
||||
}
|
||||
|
||||
// Look for completion message - but don't break if we interrupted and haven't found Last cursor yet
|
||||
// Allow some time after interrupt for the cancellation message to appear
|
||||
if (line.Contains("Conversation completed", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// If we interrupted but haven't found Last cursor, wait a bit more
|
||||
if (interrupted && !foundLastCursor && interruptTime.HasValue)
|
||||
{
|
||||
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
|
||||
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
|
||||
{
|
||||
// Continue reading for a bit more to catch the cancellation message
|
||||
this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Only break if we've completed the test or if stream completed without interruption
|
||||
if (!interrupted || (resumed && foundResumeMessage && contentLinesAfterResume >= 5))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop once we've verified the interrupt/resume flow works
|
||||
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we interrupted but didn't find Last cursor, wait a bit more for it to appear
|
||||
if (interrupted && !foundLastCursor && interruptTime.HasValue)
|
||||
{
|
||||
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
|
||||
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
|
||||
{
|
||||
this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
|
||||
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
while ((line = this.ReadLogLine(logs, waitCts.Token)) != null)
|
||||
{
|
||||
if (line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foundLastCursor = true;
|
||||
if (!resumed)
|
||||
{
|
||||
this._outputHelper.WriteLine("Resuming stream from last cursor");
|
||||
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
|
||||
resumed = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout waiting for Last cursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Timeout - check if we got enough to verify the flow
|
||||
this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
|
||||
}
|
||||
|
||||
Assert.True(foundConversationStart, "Conversation start message not found.");
|
||||
Assert.True(contentLinesBeforeInterrupt >= 2, $"Not enough content before interrupt (got {contentLinesBeforeInterrupt}).");
|
||||
|
||||
// If stream completed before interrupt could take effect, that's a timing issue
|
||||
// but we should still verify we got the conversation started
|
||||
if (!interrupted)
|
||||
{
|
||||
this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
|
||||
}
|
||||
|
||||
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
|
||||
Assert.True(foundLastCursor, "'Last cursor' message not found after interrupt.");
|
||||
Assert.True(resumed, "Stream was not resumed.");
|
||||
Assert.True(foundResumeMessage, "Resume message not found.");
|
||||
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
|
||||
});
|
||||
}
|
||||
|
||||
private static string GetTargetFramework()
|
||||
{
|
||||
string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath;
|
||||
string directory = Path.GetDirectoryName(filePath)!;
|
||||
string tfm = Path.GetFileName(directory);
|
||||
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tfm;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
|
||||
}
|
||||
|
||||
private async Task StartSharedInfrastructureAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine("Starting shared infrastructure for console app samples...");
|
||||
|
||||
// Start DTS emulator
|
||||
await this.StartDtsEmulatorAsync();
|
||||
|
||||
// Start Redis
|
||||
await this.StartRedisAsync();
|
||||
|
||||
// Wait for infrastructure to be ready
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
private async Task StartDtsEmulatorAsync()
|
||||
{
|
||||
// Start DTS emulator if it's not already running
|
||||
if (!await this.IsDtsEmulatorRunningAsync())
|
||||
{
|
||||
this._outputHelper.WriteLine("Starting DTS emulator...");
|
||||
await this.RunCommandAsync("docker", [
|
||||
"run", "-d",
|
||||
"--name", "dts-emulator",
|
||||
"-p", $"{DtsPort}:8080",
|
||||
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
|
||||
"mcr.microsoft.com/dts/dts-emulator:latest"
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartRedisAsync()
|
||||
{
|
||||
if (!await this.IsRedisRunningAsync())
|
||||
{
|
||||
this._outputHelper.WriteLine("Starting Redis...");
|
||||
await this.RunCommandAsync("docker", [
|
||||
"run", "-d",
|
||||
"--name", "redis",
|
||||
"-p", $"{RedisPort}:6379",
|
||||
"redis:latest"
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsDtsEmulatorRunningAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
|
||||
|
||||
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
|
||||
using HttpClient http2Client = new()
|
||||
{
|
||||
DefaultRequestVersion = new Version(2, 0),
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
|
||||
if (response.Content.Headers.ContentLength > 0)
|
||||
{
|
||||
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
|
||||
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
this._outputHelper.WriteLine("DTS emulator is running");
|
||||
return true;
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> IsRedisRunningAsync()
|
||||
{
|
||||
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "docker",
|
||||
Arguments = "exec redis redis-cli ping",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using Process process = new() { StartInfo = startInfo };
|
||||
if (!process.Start())
|
||||
{
|
||||
this._outputHelper.WriteLine("Failed to start docker exec command");
|
||||
return false;
|
||||
}
|
||||
|
||||
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
|
||||
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
this._outputHelper.WriteLine("Redis is running");
|
||||
return true;
|
||||
}
|
||||
|
||||
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
|
||||
{
|
||||
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
|
||||
// when multiple tests run together and share the same DTS emulator.
|
||||
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
|
||||
// Start the console app
|
||||
// Use BlockingCollection to safely read logs asynchronously captured from the process
|
||||
using BlockingCollection<OutputLog> logsContainer = [];
|
||||
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
|
||||
try
|
||||
{
|
||||
// Run the test
|
||||
await testAction(appProcess, logsContainer);
|
||||
}
|
||||
catch (OperationCanceledException e)
|
||||
{
|
||||
throw new TimeoutException("Core test logic timed out!", e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
logsContainer.CompleteAdding();
|
||||
await this.StopProcessAsync(appProcess);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a line to the process's stdin and flushes it.
|
||||
/// Logs the input being sent for debugging purposes.
|
||||
/// </summary>
|
||||
private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
|
||||
{
|
||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
|
||||
await process.StandardInput.WriteLineAsync(input);
|
||||
await process.StandardInput.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a line from the logs queue, filtering for Information level logs (stdout).
|
||||
/// Returns null if the collection is completed and empty, or if cancellation is requested.
|
||||
/// </summary>
|
||||
private string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Block until a log entry is available or cancellation is requested
|
||||
// Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed
|
||||
OutputLog log = logs.Take(cancellationToken);
|
||||
|
||||
// Check for unhandled exceptions in the logs, which are never expected (but can happen)
|
||||
if (log.Message.Contains("Unhandled exception"))
|
||||
{
|
||||
Assert.Fail("Console app encountered an unhandled exception.");
|
||||
}
|
||||
|
||||
// Only return Information level logs (stdout), skip Error logs (stderr)
|
||||
if (log.Level == LogLevel.Information)
|
||||
{
|
||||
return log.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancellation requested
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Collection is completed and empty
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = "dotnet",
|
||||
Arguments = $"run --framework {s_dotnetTargetFramework}",
|
||||
WorkingDirectory = samplePath,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardInput = true,
|
||||
};
|
||||
|
||||
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
|
||||
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
|
||||
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
|
||||
|
||||
void SetAndLogEnvironmentVariable(string key, string value)
|
||||
{
|
||||
this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
|
||||
startInfo.EnvironmentVariables[key] = value;
|
||||
}
|
||||
|
||||
// Set required environment variables for the app
|
||||
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
|
||||
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
|
||||
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
|
||||
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
|
||||
SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
|
||||
|
||||
Process process = new() { StartInfo = startInfo };
|
||||
|
||||
// Capture the output and error streams asynchronously
|
||||
// These events fire asynchronously, so we add to the blocking collection which is thread-safe
|
||||
process.ErrorDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}";
|
||||
this._outputHelper.WriteLine(logMessage);
|
||||
Debug.WriteLine(logMessage);
|
||||
try
|
||||
{
|
||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Collection is completed, ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += (sender, e) =>
|
||||
{
|
||||
if (e.Data != null)
|
||||
{
|
||||
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}";
|
||||
this._outputHelper.WriteLine(logMessage);
|
||||
Debug.WriteLine(logMessage);
|
||||
try
|
||||
{
|
||||
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Collection is completed, ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start the console app");
|
||||
}
|
||||
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(string command, string[] args)
|
||||
{
|
||||
await this.RunCommandAsync(command, workingDirectory: null, args: args);
|
||||
}
|
||||
|
||||
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
FileName = command,
|
||||
Arguments = string.Join(" ", args),
|
||||
WorkingDirectory = workingDirectory,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
|
||||
|
||||
using Process process = new() { StartInfo = startInfo };
|
||||
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
|
||||
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
|
||||
if (!process.Start())
|
||||
{
|
||||
throw new InvalidOperationException("Failed to start the command");
|
||||
}
|
||||
process.BeginErrorReadLine();
|
||||
process.BeginOutputReadLine();
|
||||
|
||||
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
|
||||
await process.WaitForExitAsync(cancellationTokenSource.Token);
|
||||
|
||||
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
|
||||
}
|
||||
|
||||
private async Task StopProcessAsync(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
|
||||
process.Kill(entireProcessTree: true);
|
||||
|
||||
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
|
||||
{
|
||||
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
|
||||
return new CancellationTokenSource(testTimeout);
|
||||
}
|
||||
}
|
||||
@@ -76,10 +76,14 @@ internal sealed class TestHelper : IDisposable
|
||||
{
|
||||
TestLoggerProvider loggerProvider = new(outputHelper);
|
||||
|
||||
// Generate a unique TaskHub name for this test instance to prevent cross-test interference
|
||||
// when multiple tests run together and share the same DTS emulator.
|
||||
string uniqueTaskHubName = $"test-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices((ctx, services) =>
|
||||
{
|
||||
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration);
|
||||
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration, uniqueTaskHubName);
|
||||
|
||||
// Register durable agents using the caller-supplied registration action and
|
||||
// apply the default chat client for agents that don't supply one themselves.
|
||||
@@ -107,11 +111,46 @@ internal sealed class TestHelper : IDisposable
|
||||
return new TestHelper(loggerProvider, host, client);
|
||||
}
|
||||
|
||||
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration)
|
||||
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration, string? taskHubName = null)
|
||||
{
|
||||
// The default value is for local development using the Durable Task Scheduler emulator.
|
||||
return configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
string? connectionString = configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"];
|
||||
|
||||
if (connectionString != null)
|
||||
{
|
||||
// If a connection string is provided, replace the TaskHub name if a custom one is specified
|
||||
if (taskHubName != null)
|
||||
{
|
||||
// Replace TaskHub in the connection string
|
||||
if (connectionString.Contains("TaskHub=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Find and replace the TaskHub value
|
||||
int taskHubIndex = connectionString.IndexOf("TaskHub=", StringComparison.OrdinalIgnoreCase);
|
||||
int taskHubValueStart = taskHubIndex + "TaskHub=".Length;
|
||||
int taskHubValueEnd = connectionString.IndexOf(';', taskHubValueStart);
|
||||
if (taskHubValueEnd == -1)
|
||||
{
|
||||
taskHubValueEnd = connectionString.Length;
|
||||
}
|
||||
|
||||
connectionString = string.Concat(
|
||||
connectionString.AsSpan(0, taskHubValueStart),
|
||||
taskHubName,
|
||||
connectionString.AsSpan(taskHubValueEnd));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Append TaskHub if it doesn't exist
|
||||
connectionString += $";TaskHub={taskHubName}";
|
||||
}
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
// Default connection string with unique TaskHub name
|
||||
string defaultTaskHub = taskHubName ?? "default";
|
||||
return $"Endpoint=http://localhost:8080;TaskHub={defaultTaskHub};Authentication=None";
|
||||
}
|
||||
|
||||
internal static ChatClient GetAzureOpenAIChatClient(IConfiguration configuration)
|
||||
|
||||
+27
-76
@@ -23,7 +23,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
|
||||
public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -31,7 +31,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
@@ -53,7 +53,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
|
||||
public async Task CreateAIAgentAsync_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -62,7 +62,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
clientFactory: (innerClient) =>
|
||||
@@ -83,7 +83,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
|
||||
public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -97,7 +97,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
options,
|
||||
clientFactory: (innerClient) => testChatClient);
|
||||
@@ -117,14 +117,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent without clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
|
||||
public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent");
|
||||
@@ -142,14 +142,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with null clientFactory works normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
|
||||
public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
@@ -168,11 +168,11 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
|
||||
public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
((AssistantClient)null!).CreateAIAgent("test-model"));
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
((AssistantClient)null!).CreateAIAgentAsync("test-model"));
|
||||
|
||||
Assert.Equal("client", exception.ParamName);
|
||||
}
|
||||
@@ -181,14 +181,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent throws ArgumentNullException when model is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullModel_ThrowsArgumentNullException()
|
||||
public async Task CreateAIAgentAsync_WithNullModel_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
assistantClient.CreateAIAgent(null!));
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
assistantClient.CreateAIAgentAsync(null!));
|
||||
|
||||
Assert.Equal("model", exception.ParamName);
|
||||
}
|
||||
@@ -197,14 +197,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
|
||||
public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentNullException>(() =>
|
||||
assistantClient.CreateAIAgent("test-model", (ChatClientAgentOptions)null!));
|
||||
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||
assistantClient.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!));
|
||||
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
@@ -286,33 +286,6 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
Assert.Equal("Original Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent with agentId and options works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
const string AgentId = "asst_abc123";
|
||||
|
||||
var options = new ChatClientAgentOptions
|
||||
{
|
||||
Name = "Override Name",
|
||||
Description = "Override Description",
|
||||
ChatOptions = new() { Instructions = "Override Instructions" }
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.GetAIAgent(AgentId, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
Assert.Equal("Override Name", agent.Name);
|
||||
Assert.Equal("Override Description", agent.Description);
|
||||
Assert.Equal("Override Instructions", agent.Instructions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync with agentId and options works correctly.
|
||||
/// </summary>
|
||||
@@ -423,23 +396,6 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
Assert.Equal("options", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgent throws ArgumentException when agentId is empty.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetAIAgent_WithEmptyAgentId_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
var options = new ChatClientAgentOptions();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<ArgumentException>(() =>
|
||||
assistantClient.GetAIAgent(string.Empty, options));
|
||||
|
||||
Assert.Equal("agentId", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty.
|
||||
/// </summary>
|
||||
@@ -461,7 +417,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithServices_PassesServicesToAgent()
|
||||
public async Task CreateAIAgentAsync_WithServices_PassesServicesToAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -469,7 +425,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
@@ -490,7 +446,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with options and services parameter correctly passes it through to the ChatClientAgent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithOptionsAndServices_PassesServicesToAgent()
|
||||
public async Task CreateAIAgentAsync_WithOptionsAndServices_PassesServicesToAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -503,7 +459,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
};
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(ModelId, options, services: serviceProvider);
|
||||
var agent = await assistantClient.CreateAIAgentAsync(ModelId, options, services: serviceProvider);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(agent);
|
||||
@@ -570,7 +526,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
/// Verify that CreateAIAgent with both clientFactory and services works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly()
|
||||
public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var assistantClient = new TestAssistantClient();
|
||||
@@ -579,7 +535,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
const string ModelId = "test-model";
|
||||
|
||||
// Act
|
||||
var agent = assistantClient.CreateAIAgent(
|
||||
var agent = await assistantClient.CreateAIAgentAsync(
|
||||
ModelId,
|
||||
instructions: "Test instructions",
|
||||
name: "Test Agent",
|
||||
@@ -622,14 +578,9 @@ public sealed class OpenAIAssistantClientExtensionsTests
|
||||
{
|
||||
}
|
||||
|
||||
public override ClientResult<Assistant> CreateAssistant(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default)
|
||||
public override Task<ClientResult<Assistant>> CreateAssistantAsync(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!;
|
||||
}
|
||||
|
||||
public override ClientResult<Assistant> GetAssistant(string assistantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!;
|
||||
return Task.FromResult<ClientResult<Assistant>>(ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!);
|
||||
}
|
||||
|
||||
public override async Task<ClientResult<Assistant>> GetAssistantAsync(string assistantId, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -277,6 +277,48 @@ public class AgentExtensionsTests
|
||||
Assert.Equal("Complex response", result.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateFromAgent_InvokeWithAdditionalProperties_PropagatesAdditionalPropertiesToChildAgentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var expectedResponse = new AgentResponse
|
||||
{
|
||||
AgentId = "agent-123",
|
||||
ResponseId = "response-456",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
|
||||
};
|
||||
|
||||
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
|
||||
var aiFunction = testAgent.AsAIFunction();
|
||||
|
||||
// Use reflection to set the protected CurrentContext property
|
||||
var context = new FunctionInvocationContext()
|
||||
{
|
||||
Options = new()
|
||||
{
|
||||
AdditionalProperties = new AdditionalPropertiesDictionary
|
||||
{
|
||||
{ "customProperty1", "value1" },
|
||||
{ "customProperty2", 42 }
|
||||
}
|
||||
}
|
||||
};
|
||||
SetFunctionInvokingChatClientCurrentContext(context);
|
||||
|
||||
// Act
|
||||
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
|
||||
var result = await aiFunction.InvokeAsync(arguments);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("Complex response", result.ToString());
|
||||
Assert.NotNull(testAgent.ReceivedAgentRunOptions);
|
||||
Assert.NotNull(testAgent.ReceivedAgentRunOptions!.AdditionalProperties);
|
||||
Assert.Equal("value1", testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty1"]);
|
||||
Assert.Equal(42, testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty2"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MyAgent", "MyAgent")]
|
||||
[InlineData("Agent123", "Agent123")]
|
||||
@@ -302,6 +344,22 @@ public class AgentExtensionsTests
|
||||
Assert.Equal(expectedFunctionName, result.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uses reflection to set the protected static CurrentContext property on FunctionInvokingChatClient.
|
||||
/// </summary>
|
||||
private static void SetFunctionInvokingChatClientCurrentContext(FunctionInvocationContext? context)
|
||||
{
|
||||
// Access the private static field _currentContext which is an AsyncLocal<FunctionInvocationContext?>
|
||||
var currentContextField = typeof(FunctionInvokingChatClient).GetField(
|
||||
"_currentContext",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
|
||||
if (currentContextField?.GetValue(null) is AsyncLocal<FunctionInvocationContext?> asyncLocal)
|
||||
{
|
||||
asyncLocal.Value = context;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation of AIAgent for testing purposes.
|
||||
/// </summary>
|
||||
@@ -334,6 +392,7 @@ public class AgentExtensionsTests
|
||||
public override string? Description { get; }
|
||||
|
||||
public List<ChatMessage> ReceivedMessages { get; } = [];
|
||||
public AgentRunOptions? ReceivedAgentRunOptions { get; private set; }
|
||||
public CancellationToken LastCancellationToken { get; private set; }
|
||||
public int RunAsyncCallCount { get; private set; }
|
||||
|
||||
@@ -346,6 +405,7 @@ public class AgentExtensionsTests
|
||||
this.RunAsyncCallCount++;
|
||||
this.LastCancellationToken = cancellationToken;
|
||||
this.ReceivedMessages.AddRange(messages);
|
||||
this.ReceivedAgentRunOptions = options;
|
||||
|
||||
if (this._exceptionToThrow is not null)
|
||||
{
|
||||
|
||||
+3
-3
@@ -48,7 +48,7 @@ public class OpenAIAssistantClientExtensionsTests
|
||||
Tools = [weatherFunction]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
|
||||
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
|
||||
model: s_config.ChatModelId!,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
@@ -115,7 +115,7 @@ public class OpenAIAssistantClientExtensionsTests
|
||||
Tools = [codeInterpreterTool]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
|
||||
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
|
||||
model: s_config.ChatModelId!,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
@@ -193,7 +193,7 @@ public class OpenAIAssistantClientExtensionsTests
|
||||
Tools = [fileSearchTool]
|
||||
}
|
||||
}),
|
||||
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
|
||||
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
|
||||
model: s_config.ChatModelId!,
|
||||
options: new ChatClientAgentOptions()
|
||||
{
|
||||
|
||||
@@ -31,11 +31,9 @@ from agent_framework import (
|
||||
AgentThread,
|
||||
BaseAgent,
|
||||
ChatMessage,
|
||||
Contents,
|
||||
DataContent,
|
||||
Content,
|
||||
Role,
|
||||
TextContent,
|
||||
UriContent,
|
||||
normalize_messages,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
from agent_framework.observability import use_agent_instrumentation
|
||||
@@ -236,7 +234,7 @@ class A2AAgent(BaseAgent):
|
||||
Yields:
|
||||
An agent response item.
|
||||
"""
|
||||
messages = self._normalize_messages(messages)
|
||||
messages = normalize_messages(messages)
|
||||
a2a_message = self._prepare_message_for_a2a(messages[-1])
|
||||
|
||||
response_stream = self.client.send_message(a2a_message)
|
||||
@@ -332,7 +330,7 @@ class A2AAgent(BaseAgent):
|
||||
A2APart(
|
||||
root=FilePart(
|
||||
file=FileWithBytes(
|
||||
bytes=_get_uri_data(content.uri),
|
||||
bytes=_get_uri_data(content.uri), # type: ignore[arg-type]
|
||||
mime_type=content.media_type,
|
||||
),
|
||||
metadata=content.additional_properties,
|
||||
@@ -361,19 +359,19 @@ class A2AAgent(BaseAgent):
|
||||
metadata=cast(dict[str, Any], message.additional_properties),
|
||||
)
|
||||
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Contents]:
|
||||
"""Parse A2A Parts into Agent Framework Contents.
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
|
||||
"""Parse A2A Parts into Agent Framework Content.
|
||||
|
||||
Transforms A2A protocol Parts into framework-native Content objects,
|
||||
handling text, file (URI/bytes), and data parts with metadata preservation.
|
||||
"""
|
||||
contents: list[Contents] = []
|
||||
contents: list[Content] = []
|
||||
for part in parts:
|
||||
inner_part = part.root
|
||||
match inner_part.kind:
|
||||
case "text":
|
||||
contents.append(
|
||||
TextContent(
|
||||
Content.from_text(
|
||||
text=inner_part.text,
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
@@ -382,7 +380,7 @@ class A2AAgent(BaseAgent):
|
||||
case "file":
|
||||
if isinstance(inner_part.file, FileWithUri):
|
||||
contents.append(
|
||||
UriContent(
|
||||
Content.from_uri(
|
||||
uri=inner_part.file.uri,
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
@@ -391,7 +389,7 @@ class A2AAgent(BaseAgent):
|
||||
)
|
||||
elif isinstance(inner_part.file, FileWithBytes):
|
||||
contents.append(
|
||||
DataContent(
|
||||
Content.from_data(
|
||||
data=base64.b64decode(inner_part.file.bytes),
|
||||
media_type=inner_part.file.mime_type or "",
|
||||
additional_properties=inner_part.metadata,
|
||||
@@ -400,7 +398,7 @@ class A2AAgent(BaseAgent):
|
||||
)
|
||||
case "data":
|
||||
contents.append(
|
||||
TextContent(
|
||||
Content.from_text(
|
||||
text=json.dumps(inner_part.data),
|
||||
additional_properties=inner_part.metadata,
|
||||
raw_representation=inner_part,
|
||||
|
||||
@@ -24,12 +24,8 @@ from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
ChatMessage,
|
||||
DataContent,
|
||||
ErrorContent,
|
||||
HostedFileContent,
|
||||
Content,
|
||||
Role,
|
||||
TextContent,
|
||||
UriContent,
|
||||
)
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, raises
|
||||
@@ -289,8 +285,8 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
|
||||
|
||||
# Verify conversion
|
||||
assert len(contents) == 2
|
||||
assert isinstance(contents[0], TextContent)
|
||||
assert isinstance(contents[1], TextContent)
|
||||
assert contents[0].type == "text"
|
||||
assert contents[1].type == "text"
|
||||
assert contents[0].text == "First part"
|
||||
assert contents[1].text == "Second part"
|
||||
|
||||
@@ -299,7 +295,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
|
||||
"""Test _prepare_message_for_a2a with ErrorContent."""
|
||||
|
||||
# Create ChatMessage with ErrorContent
|
||||
error_content = ErrorContent(message="Test error message")
|
||||
error_content = Content.from_error(message="Test error message")
|
||||
message = ChatMessage(role=Role.USER, contents=[error_content])
|
||||
|
||||
# Convert to A2A message
|
||||
@@ -314,7 +310,7 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with UriContent."""
|
||||
|
||||
# Create ChatMessage with UriContent
|
||||
uri_content = UriContent(uri="http://example.com/file.pdf", media_type="application/pdf")
|
||||
uri_content = Content.from_uri(uri="http://example.com/file.pdf", media_type="application/pdf")
|
||||
message = ChatMessage(role=Role.USER, contents=[uri_content])
|
||||
|
||||
# Convert to A2A message
|
||||
@@ -330,7 +326,7 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
|
||||
"""Test _prepare_message_for_a2a with DataContent."""
|
||||
|
||||
# Create ChatMessage with DataContent (base64 data URI)
|
||||
data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
|
||||
data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
|
||||
message = ChatMessage(role=Role.USER, contents=[data_content])
|
||||
|
||||
# Convert to A2A message
|
||||
@@ -368,7 +364,7 @@ async def test_run_stream_with_message_response(a2a_agent: A2AAgent, mock_a2a_cl
|
||||
assert len(updates[0].contents) == 1
|
||||
|
||||
content = updates[0].contents[0]
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.type == "text"
|
||||
assert content.text == "Streaming response from agent!"
|
||||
|
||||
assert updates[0].response_id == "msg-stream-123"
|
||||
@@ -414,10 +410,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
message = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[
|
||||
TextContent(text="Here's the analysis:"),
|
||||
DataContent(data=b"binary data", media_type="application/octet-stream"),
|
||||
UriContent(uri="https://example.com/image.png", media_type="image/png"),
|
||||
TextContent(text='{"structured": "data"}'),
|
||||
Content.from_text(text="Here's the analysis:"),
|
||||
Content.from_data(data=b"binary data", media_type="application/octet-stream"),
|
||||
Content.from_uri(uri="https://example.com/image.png", media_type="image/png"),
|
||||
Content.from_text(text='{"structured": "data"}'),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -445,7 +441,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
assert isinstance(contents[0], TextContent)
|
||||
assert contents[0].type == "text"
|
||||
assert contents[0].text == '{"key": "value", "number": 42}'
|
||||
assert contents[0].additional_properties == {"source": "test"}
|
||||
|
||||
@@ -470,7 +466,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
|
||||
# Create message with hosted file content
|
||||
message = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[HostedFileContent(file_id="hosted://storage/document.pdf")],
|
||||
contents=[Content.from_hosted_file(file_id="hosted://storage/document.pdf")],
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message) # noqa: SLF001
|
||||
@@ -507,7 +503,7 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
|
||||
|
||||
assert len(contents) == 1
|
||||
|
||||
assert isinstance(contents[0], UriContent)
|
||||
assert contents[0].type == "uri"
|
||||
assert contents[0].uri == "hosted://storage/document.pdf"
|
||||
assert contents[0].media_type == "" # Converted None to empty string
|
||||
|
||||
|
||||
@@ -17,12 +17,10 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
DataContent,
|
||||
FunctionCallContent,
|
||||
Content,
|
||||
use_chat_middleware,
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework._middleware import use_chat_middleware
|
||||
from agent_framework._tools import use_function_invocation
|
||||
from agent_framework._types import BaseContent, Contents
|
||||
from agent_framework.observability import use_instrumentation
|
||||
|
||||
from ._event_converters import AGUIEventConverter
|
||||
@@ -53,26 +51,11 @@ else:
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerFunctionCallContent(BaseContent):
|
||||
"""Wrapper for server function calls to prevent client re-execution.
|
||||
|
||||
All function calls from the remote server are server-side executions.
|
||||
This wrapper prevents @use_function_invocation from trying to execute them again.
|
||||
"""
|
||||
|
||||
function_call_content: FunctionCallContent
|
||||
|
||||
def __init__(self, function_call_content: FunctionCallContent) -> None:
|
||||
"""Initialize with the function call content."""
|
||||
super().__init__(type="server_function_call")
|
||||
self.function_call_content = function_call_content
|
||||
|
||||
|
||||
def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None:
|
||||
"""Replace ServerFunctionCallContent instances with their underlying call content."""
|
||||
def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None:
|
||||
"""Replace server_function_call instances with their underlying call content."""
|
||||
for idx, content in enumerate(contents):
|
||||
if isinstance(content, ServerFunctionCallContent):
|
||||
contents[idx] = content.function_call_content # type: ignore[assignment]
|
||||
if content.type == "server_function_call": # type: ignore[union-attr]
|
||||
contents[idx] = content.function_call # type: ignore[assignment, union-attr]
|
||||
|
||||
|
||||
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]])
|
||||
@@ -93,7 +76,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
|
||||
@wraps(original_get_streaming_response)
|
||||
async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
|
||||
async for update in original_get_streaming_response(self, *args, **kwargs):
|
||||
_unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents))
|
||||
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
|
||||
yield update
|
||||
|
||||
chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment]
|
||||
@@ -105,9 +88,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
|
||||
response = await original_get_response(self, *args, **kwargs)
|
||||
if response.messages:
|
||||
for message in response.messages:
|
||||
_unwrap_server_function_call_contents(
|
||||
cast(MutableSequence[Contents | dict[str, Any]], message.contents)
|
||||
)
|
||||
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], message.contents))
|
||||
return response
|
||||
|
||||
chat_client.get_response = response_wrapper # type: ignore[assignment]
|
||||
@@ -289,13 +270,13 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
|
||||
last_message = messages[-1]
|
||||
|
||||
for content in last_message.contents:
|
||||
if isinstance(content, DataContent) and content.media_type == "application/json":
|
||||
if isinstance(content, Content) and content.type == "data" and content.media_type == "application/json":
|
||||
try:
|
||||
uri = content.uri
|
||||
if uri.startswith("data:application/json;base64,"):
|
||||
if uri.startswith("data:application/json;base64,"): # type: ignore[union-attr]
|
||||
import base64
|
||||
|
||||
encoded_data = uri.split(",", 1)[1]
|
||||
encoded_data = uri.split(",", 1)[1] # type: ignore[union-attr]
|
||||
decoded_bytes = base64.b64decode(encoded_data)
|
||||
state = json.loads(decoded_bytes.decode("utf-8"))
|
||||
|
||||
@@ -433,19 +414,19 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
|
||||
)
|
||||
# Distinguish client vs server tools
|
||||
for i, content in enumerate(update.contents):
|
||||
if isinstance(content, FunctionCallContent):
|
||||
if content.type == "function_call": # type: ignore[attr-defined]
|
||||
logger.debug(
|
||||
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
|
||||
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}" # type: ignore[attr-defined]
|
||||
)
|
||||
if content.name in client_tool_set:
|
||||
if content.name in client_tool_set: # type: ignore[attr-defined]
|
||||
# Client tool - let @use_function_invocation execute it
|
||||
if not content.additional_properties:
|
||||
content.additional_properties = {}
|
||||
content.additional_properties["agui_thread_id"] = thread_id
|
||||
if not content.additional_properties: # type: ignore[attr-defined]
|
||||
content.additional_properties = {} # type: ignore[attr-defined]
|
||||
content.additional_properties["agui_thread_id"] = thread_id # type: ignore[attr-defined]
|
||||
else:
|
||||
# Server tool - wrap so @use_function_invocation ignores it
|
||||
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
|
||||
self._register_server_tool_placeholder(content.name)
|
||||
update.contents[i] = ServerFunctionCallContent(content) # type: ignore
|
||||
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}") # type: ignore[union-attr]
|
||||
self._register_server_tool_placeholder(content.name) # type: ignore[arg-type]
|
||||
update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore
|
||||
|
||||
yield update
|
||||
|
||||
@@ -6,12 +6,9 @@ from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
ChatResponseUpdate,
|
||||
ErrorContent,
|
||||
Content,
|
||||
FinishReason,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Role,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
|
||||
@@ -117,7 +114,7 @@ class AGUIEventConverter:
|
||||
return ChatResponseUpdate(
|
||||
role=Role.ASSISTANT,
|
||||
message_id=self.current_message_id,
|
||||
contents=[TextContent(text=delta)],
|
||||
contents=[Content.from_text(text=delta)],
|
||||
)
|
||||
|
||||
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
|
||||
@@ -133,7 +130,7 @@ class AGUIEventConverter:
|
||||
return ChatResponseUpdate(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
call_id=self.current_tool_call_id or "",
|
||||
name=self.current_tool_name or "",
|
||||
arguments="",
|
||||
@@ -149,7 +146,7 @@ class AGUIEventConverter:
|
||||
return ChatResponseUpdate(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
call_id=self.current_tool_call_id or "",
|
||||
name=self.current_tool_name or "",
|
||||
arguments=delta,
|
||||
@@ -170,7 +167,7 @@ class AGUIEventConverter:
|
||||
return ChatResponseUpdate(
|
||||
role=Role.TOOL,
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id=tool_call_id,
|
||||
result=result,
|
||||
)
|
||||
@@ -197,7 +194,7 @@ class AGUIEventConverter:
|
||||
role=Role.ASSISTANT,
|
||||
finish_reason=FinishReason.CONTENT_FILTER,
|
||||
contents=[
|
||||
ErrorContent(
|
||||
Content.from_error(
|
||||
message=error_message,
|
||||
error_code="RUN_ERROR",
|
||||
)
|
||||
|
||||
@@ -25,14 +25,11 @@ from ag_ui.core import (
|
||||
)
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
Content,
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse
|
||||
from ._utils import extract_state_from_tool_args, generate_event_id, make_json_safe, safe_json_parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,20 +93,22 @@ class AgentFrameworkEventBridge:
|
||||
logger.info(f"Processing AgentRunUpdate with {len(update.contents)} content items")
|
||||
for idx, content in enumerate(update.contents):
|
||||
logger.info(f" Content {idx}: type={type(content).__name__}")
|
||||
if isinstance(content, TextContent):
|
||||
events.extend(self._handle_text_content(content))
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
events.extend(self._handle_function_call_content(content))
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
events.extend(self._handle_function_result_content(content))
|
||||
elif isinstance(content, FunctionApprovalRequestContent):
|
||||
events.extend(self._handle_function_approval_request_content(content))
|
||||
|
||||
match content.type:
|
||||
case "text":
|
||||
events.extend(self._handle_text_content(content))
|
||||
case "function_call":
|
||||
events.extend(self._handle_function_call_content(content))
|
||||
case "function_result":
|
||||
events.extend(self._handle_function_result_content(content))
|
||||
case "function_approval_request":
|
||||
events.extend(self._handle_function_approval_request_content(content))
|
||||
case _:
|
||||
logger.warning(f" Unsupported content type: {content.type}, skipping.")
|
||||
return events
|
||||
|
||||
def _handle_text_content(self, content: TextContent) -> list[BaseEvent]:
|
||||
def _handle_text_content(self, content: Content) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
logger.info(f" TextContent found: length={len(content.text)}")
|
||||
logger.info(f" TextContent found: length={len(content.text)}") # type: ignore[arg-type]
|
||||
logger.info(
|
||||
" Flags: skip_text_content=%s, should_stop_after_confirm=%s",
|
||||
self.skip_text_content,
|
||||
@@ -122,7 +121,7 @@ class AgentFrameworkEventBridge:
|
||||
|
||||
if self.should_stop_after_confirm:
|
||||
logger.info(" SKIPPING TextContent: waiting for confirm_changes response")
|
||||
self.suppressed_summary += content.text
|
||||
self.suppressed_summary += content.text # type: ignore[operator]
|
||||
logger.info(f" Suppressed summary length={len(self.suppressed_summary)}")
|
||||
return events
|
||||
|
||||
@@ -150,14 +149,14 @@ class AgentFrameworkEventBridge:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
def _handle_function_call_content(self, content: FunctionCallContent) -> list[BaseEvent]:
|
||||
def _handle_function_call_content(self, content: Content) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if content.name:
|
||||
logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})")
|
||||
|
||||
if not content.name and not content.call_id and not self.current_tool_call_name:
|
||||
args_length = len(str(content.arguments)) if content.arguments else 0
|
||||
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
|
||||
logger.warning(f"Content 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)
|
||||
@@ -178,7 +177,11 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_id = tool_call_id
|
||||
|
||||
if content.arguments:
|
||||
delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments)
|
||||
delta_str = (
|
||||
content.arguments
|
||||
if isinstance(content.arguments, str)
|
||||
else json.dumps(make_json_safe(content.arguments))
|
||||
)
|
||||
logger.info(f"Emitting ToolCallArgsEvent with delta_length={len(delta_str)}, id='{tool_call_id}'")
|
||||
args_event = ToolCallArgsEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
@@ -190,7 +193,7 @@ class AgentFrameworkEventBridge:
|
||||
|
||||
return events
|
||||
|
||||
def _coalesce_tool_call_id(self, content: FunctionCallContent) -> str:
|
||||
def _coalesce_tool_call_id(self, content: Content) -> str:
|
||||
if content.call_id:
|
||||
return content.call_id
|
||||
if self.current_tool_call_id:
|
||||
@@ -286,7 +289,7 @@ class AgentFrameworkEventBridge:
|
||||
self.pending_state_updates[state_key] = state_value
|
||||
return events
|
||||
|
||||
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
|
||||
def _handle_function_result_content(self, content: Content) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
if content.call_id:
|
||||
end_event = ToolCallEndEvent(
|
||||
@@ -310,7 +313,7 @@ class AgentFrameworkEventBridge:
|
||||
|
||||
result_event = ToolCallResultEvent(
|
||||
message_id=result_message_id,
|
||||
tool_call_id=content.call_id,
|
||||
tool_call_id=content.call_id, # type: ignore[arg-type]
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
@@ -367,7 +370,7 @@ class AgentFrameworkEventBridge:
|
||||
self.current_tool_call_name = None
|
||||
return events
|
||||
|
||||
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
|
||||
def _emit_confirm_changes_tool_call(self, function_call: Content | None = None) -> list[BaseEvent]:
|
||||
"""Emit a confirm_changes tool call for Dojo UI compatibility.
|
||||
|
||||
Args:
|
||||
@@ -392,7 +395,7 @@ class AgentFrameworkEventBridge:
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"function_arguments": make_json_safe(function_call.parse_arguments() or {}),
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
@@ -419,7 +422,7 @@ class AgentFrameworkEventBridge:
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
return events
|
||||
|
||||
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
|
||||
def _emit_function_approval_tool_call(self, function_call: Content) -> 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:
|
||||
@@ -436,7 +439,7 @@ class AgentFrameworkEventBridge:
|
||||
args_dict = {
|
||||
"function_name": function_call.name,
|
||||
"function_call_id": function_call.call_id,
|
||||
"function_arguments": function_call.parse_arguments() or {},
|
||||
"function_arguments": make_json_safe(function_call.parse_arguments() or {}),
|
||||
"steps": [
|
||||
{
|
||||
"description": f"Execute {function_call.name}",
|
||||
@@ -462,13 +465,13 @@ class AgentFrameworkEventBridge:
|
||||
logger.info("Set flag to stop run after confirm_changes")
|
||||
return events
|
||||
|
||||
def _handle_function_approval_request_content(self, content: FunctionApprovalRequestContent) -> list[BaseEvent]:
|
||||
def _handle_function_approval_request_content(self, content: Content) -> list[BaseEvent]:
|
||||
events: list[BaseEvent] = []
|
||||
logger.info("=== FUNCTION APPROVAL REQUEST ===")
|
||||
logger.info(f" Function: {content.function_call.name}")
|
||||
logger.info(f" Call ID: {content.function_call.call_id}")
|
||||
logger.info(f" Function: {content.function_call.name}") # type: ignore[union-attr]
|
||||
logger.info(f" Call ID: {content.function_call.call_id}") # type: ignore[union-attr]
|
||||
|
||||
parsed_args = content.function_call.parse_arguments()
|
||||
parsed_args = content.function_call.parse_arguments() # type: ignore[union-attr]
|
||||
parsed_arg_keys = list(parsed_args.keys()) if parsed_args else "None"
|
||||
logger.info(f" Parsed args keys: {parsed_arg_keys}")
|
||||
|
||||
@@ -478,12 +481,12 @@ class AgentFrameworkEventBridge:
|
||||
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
|
||||
)
|
||||
for state_key, config in self.predict_state_config.items():
|
||||
if config["tool"] != content.function_call.name:
|
||||
if config["tool"] != content.function_call.name: # type: ignore[union-attr]
|
||||
continue
|
||||
tool_arg_name = config["tool_argument"]
|
||||
logger.info(
|
||||
" MATCHED tool '%s' for state key '%s', arg='%s'",
|
||||
content.function_call.name,
|
||||
content.function_call.name, # type: ignore[union-attr]
|
||||
state_key,
|
||||
tool_arg_name,
|
||||
)
|
||||
@@ -500,11 +503,11 @@ class AgentFrameworkEventBridge:
|
||||
)
|
||||
events.append(state_snapshot)
|
||||
|
||||
if content.function_call.call_id:
|
||||
if content.function_call.call_id: # type: ignore[union-attr]
|
||||
end_event = ToolCallEndEvent(
|
||||
tool_call_id=content.function_call.call_id,
|
||||
tool_call_id=content.function_call.call_id, # type: ignore[union-attr]
|
||||
)
|
||||
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
|
||||
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'") # type: ignore[union-attr]
|
||||
events.append(end_event)
|
||||
|
||||
# Emit the function_approval_request custom event for UI implementations that support it
|
||||
@@ -513,18 +516,18 @@ class AgentFrameworkEventBridge:
|
||||
value={
|
||||
"id": content.id,
|
||||
"function_call": {
|
||||
"call_id": content.function_call.call_id,
|
||||
"name": content.function_call.name,
|
||||
"arguments": content.function_call.parse_arguments(),
|
||||
"call_id": content.function_call.call_id, # type: ignore[union-attr]
|
||||
"name": content.function_call.name, # type: ignore[union-attr]
|
||||
"arguments": content.function_call.parse_arguments(), # type: ignore[union-attr]
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
|
||||
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") # type: ignore[union-attr]
|
||||
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))
|
||||
events.extend(self._emit_function_approval_tool_call(content.function_call)) # type: ignore[arg-type]
|
||||
|
||||
# Signal orchestrator to stop the run and wait for user approval response
|
||||
self.should_stop_after_confirm = True
|
||||
|
||||
@@ -8,11 +8,8 @@ from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
Content,
|
||||
Role,
|
||||
TextContent,
|
||||
prepare_function_call_results,
|
||||
)
|
||||
|
||||
@@ -20,6 +17,7 @@ from ._utils import (
|
||||
AGUI_TO_FRAMEWORK_ROLE,
|
||||
FRAMEWORK_TO_AGUI_ROLE,
|
||||
get_role_value,
|
||||
make_json_safe,
|
||||
normalize_agui_role,
|
||||
safe_json_parse,
|
||||
)
|
||||
@@ -40,11 +38,11 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
tool_ids = {
|
||||
str(content.call_id)
|
||||
for content in msg.contents or []
|
||||
if isinstance(content, FunctionCallContent) and content.call_id
|
||||
if content.type == "function_call" and content.call_id
|
||||
}
|
||||
confirm_changes_call = None
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
|
||||
if content.type == "function_call" and content.name == "confirm_changes":
|
||||
confirm_changes_call = content
|
||||
break
|
||||
|
||||
@@ -59,7 +57,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
approval_call_ids: set[str] = set()
|
||||
approval_accepted: bool | None = None
|
||||
for content in msg.contents or []:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if content.type == "function_approval_response":
|
||||
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:
|
||||
@@ -79,7 +77,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if approval_accepted else "Rejected",
|
||||
)
|
||||
@@ -93,12 +91,12 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
if pending_confirm_changes_id:
|
||||
user_text = ""
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent):
|
||||
user_text = content.text
|
||||
if content.type == "text":
|
||||
user_text = content.text # type: ignore[assignment]
|
||||
break
|
||||
|
||||
try:
|
||||
parsed = json.loads(user_text)
|
||||
parsed = json.loads(user_text) # type: ignore[arg-type]
|
||||
if "accepted" in parsed:
|
||||
logger.info(
|
||||
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
|
||||
@@ -106,7 +104,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id=pending_confirm_changes_id,
|
||||
result="Confirmed" if parsed.get("accepted") else "Rejected",
|
||||
)
|
||||
@@ -130,7 +128,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
synthetic_result = ChatMessage(
|
||||
role="tool",
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id=pending_call_id,
|
||||
result="Tool execution skipped - user provided follow-up message",
|
||||
)
|
||||
@@ -149,7 +147,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
continue
|
||||
keep = False
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
if content.type == "function_result" and content.call_id:
|
||||
call_id = str(content.call_id)
|
||||
if call_id in pending_tool_call_ids:
|
||||
keep = True
|
||||
@@ -175,7 +173,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> 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):
|
||||
if role_value == "tool" and msg.contents and msg.contents[0].type == "function_result":
|
||||
call_id = str(msg.contents[0].call_id)
|
||||
key: Any = (role_value, call_id)
|
||||
|
||||
@@ -184,7 +182,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
existing_msg = unique_messages[existing_idx]
|
||||
|
||||
existing_result = None
|
||||
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
|
||||
if existing_msg.contents and existing_msg.contents[0].type == "function_result":
|
||||
existing_result = existing_msg.contents[0].result
|
||||
new_result = msg.contents[0].result
|
||||
|
||||
@@ -198,11 +196,9 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
|
||||
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)
|
||||
):
|
||||
elif role_value == "assistant" and msg.contents and any(c.type == "function_call" 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)
|
||||
sorted(str(c.call_id) for c in msg.contents if c.type == "function_call" and c.call_id)
|
||||
)
|
||||
key = (role_value, tool_call_ids)
|
||||
|
||||
@@ -270,20 +266,19 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
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)
|
||||
function_payload_dict["arguments"] = json.dumps(make_json_safe(modified_args))
|
||||
else:
|
||||
function_payload_dict["arguments"] = modified_args
|
||||
return
|
||||
|
||||
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
|
||||
def _find_matching_func_call(call_id: str) -> Content | 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
|
||||
if content.type == "function_call" and content.call_id == call_id and content.name != "confirm_changes":
|
||||
return content
|
||||
return None
|
||||
|
||||
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
|
||||
@@ -301,9 +296,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
continue
|
||||
direct_call = None
|
||||
confirm_call = None
|
||||
sibling_calls: list[FunctionCallContent] = []
|
||||
sibling_calls: list[Content] = []
|
||||
for content in prev_msg.contents or []:
|
||||
if not isinstance(content, FunctionCallContent):
|
||||
if content.type != "function_call":
|
||||
continue
|
||||
if content.call_id == tool_call_id:
|
||||
direct_call = content
|
||||
@@ -383,7 +378,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
# 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)
|
||||
approval_payload_text = (
|
||||
result_content if isinstance(result_content, str) else json.dumps(make_json_safe(parsed))
|
||||
)
|
||||
|
||||
# Log the full approval payload to debug modified arguments
|
||||
import logging
|
||||
@@ -407,7 +404,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
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
|
||||
c.type == "function_result" and c.call_id == approval_call_id
|
||||
for c in (m.contents or [])
|
||||
)
|
||||
)
|
||||
@@ -460,15 +457,17 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
|
||||
# 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
|
||||
json.dumps(make_json_safe(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),
|
||||
func_call_for_approval = Content.from_function_call(
|
||||
call_id=matching_func_call.call_id, # type: ignore[arg-type]
|
||||
name=matching_func_call.name, # type: ignore[arg-type]
|
||||
arguments=json.dumps(make_json_safe(filtered_args)),
|
||||
)
|
||||
logger.info(f"Using modified arguments from approval: {filtered_args}")
|
||||
else:
|
||||
@@ -476,7 +475,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
func_call_for_approval = matching_func_call
|
||||
|
||||
# Create FunctionApprovalResponseContent for the agent framework
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=accepted,
|
||||
id=str(approval_call_id),
|
||||
function_call=func_call_for_approval,
|
||||
@@ -491,7 +490,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
# Keep the old behavior for backwards compatibility
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.USER,
|
||||
contents=[TextContent(text=approval_payload_text)],
|
||||
contents=[Content.from_text(text=approval_payload_text)],
|
||||
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
|
||||
)
|
||||
if "id" in msg:
|
||||
@@ -511,7 +510,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
func_result = str(result_content)
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
|
||||
contents=[Content.from_function_result(call_id=str(tool_call_id), result=func_result)],
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
@@ -527,21 +526,21 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
|
||||
chat_msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
|
||||
contents=[Content.from_function_result(call_id=str(tool_call_id), result=result_content)],
|
||||
)
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
result.append(chat_msg)
|
||||
continue
|
||||
|
||||
# If assistant message includes tool calls, convert to FunctionCallContent(s)
|
||||
# If assistant message includes tool calls, convert to Content.from_function_call(s)
|
||||
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
|
||||
if tool_calls:
|
||||
contents: list[Any] = []
|
||||
# Include any assistant text content if present
|
||||
content_text = msg.get("content")
|
||||
if isinstance(content_text, str) and content_text:
|
||||
contents.append(TextContent(text=content_text))
|
||||
contents.append(Content.from_text(text=content_text))
|
||||
# Convert each tool call entry
|
||||
for tc in tool_calls:
|
||||
if not isinstance(tc, dict):
|
||||
@@ -558,7 +557,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
arguments = func_dict.get("arguments")
|
||||
|
||||
contents.append(
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
@@ -580,14 +579,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
approval_contents: list[Any] = []
|
||||
for approval in msg["function_approvals"]:
|
||||
# Create FunctionCallContent with the modified arguments
|
||||
func_call = FunctionCallContent(
|
||||
func_call = Content.from_function_call(
|
||||
call_id=approval.get("call_id", ""),
|
||||
name=approval.get("name", ""),
|
||||
arguments=approval.get("arguments", {}),
|
||||
)
|
||||
|
||||
# Create the approval response
|
||||
approval_response = FunctionApprovalResponseContent(
|
||||
approval_response = Content.from_function_approval_response(
|
||||
approved=approval.get("approved", True),
|
||||
id=approval.get("id", ""),
|
||||
function_call=func_call,
|
||||
@@ -599,9 +598,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
|
||||
# Regular text message
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)])
|
||||
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=content)])
|
||||
else:
|
||||
chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))])
|
||||
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=str(content))])
|
||||
|
||||
if "id" in msg:
|
||||
chat_msg.message_id = msg["id"]
|
||||
@@ -652,9 +651,9 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
tool_result_call_id: str | None = None
|
||||
|
||||
for content in msg.contents:
|
||||
if isinstance(content, TextContent):
|
||||
content_text += content.text
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
if content.type == "text":
|
||||
content_text += content.text # type: ignore[operator]
|
||||
elif content.type == "function_call":
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": content.call_id,
|
||||
@@ -665,7 +664,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
|
||||
},
|
||||
}
|
||||
)
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
elif content.type == "function_result":
|
||||
# Tool result content - extract call_id and result
|
||||
tool_result_call_id = content.call_id
|
||||
# Serialize result to string using core utility
|
||||
@@ -702,8 +701,13 @@ def extract_text_from_contents(contents: list[Any]) -> str:
|
||||
"""
|
||||
text_parts: list[str] = []
|
||||
for content in contents:
|
||||
if isinstance(content, TextContent):
|
||||
text_parts.append(content.text)
|
||||
if type_ := getattr(content, "type", None):
|
||||
if type_ == "text_reasoning":
|
||||
continue
|
||||
if text := getattr(content, "text", None):
|
||||
text_parts.append(text)
|
||||
continue
|
||||
# TODO (moonbox3): should this handle both text and text_reasoning?
|
||||
elif hasattr(content, "text"):
|
||||
text_parts.append(content.text)
|
||||
return "".join(text_parts)
|
||||
@@ -768,7 +772,7 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
|
||||
if arguments is None:
|
||||
function_payload_dict["arguments"] = ""
|
||||
elif not isinstance(arguments, str):
|
||||
function_payload_dict["arguments"] = json.dumps(arguments)
|
||||
function_payload_dict["arguments"] = json.dumps(make_json_safe(arguments))
|
||||
|
||||
# Normalize tool_call_id to toolCallId for tool messages
|
||||
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
|
||||
|
||||
@@ -9,13 +9,10 @@ from typing import TYPE_CHECKING, Any
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
from agent_framework import (
|
||||
ChatMessage,
|
||||
FunctionApprovalResponseContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
Content,
|
||||
)
|
||||
|
||||
from .._utils import get_role_value, safe_json_parse
|
||||
from .._utils import get_role_value, make_json_safe, safe_json_parse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .._events import AgentFrameworkEventBridge
|
||||
@@ -37,9 +34,9 @@ def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
|
||||
resolved_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.call_id:
|
||||
if content.type == "function_call" and content.call_id:
|
||||
pending_ids.add(str(content.call_id))
|
||||
elif isinstance(content, FunctionResultContent) and content.call_id:
|
||||
elif content.type == "function_result" and content.call_id:
|
||||
resolved_ids.add(str(content.call_id))
|
||||
return pending_ids - resolved_ids
|
||||
|
||||
@@ -56,7 +53,7 @@ def is_state_context_message(message: ChatMessage) -> bool:
|
||||
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:"):
|
||||
if content.type == "text" and content.text.startswith("Current state of the application:"): # type: ignore[union-attr]
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -139,7 +136,7 @@ def tool_calls_match_state(
|
||||
if get_role_value(msg) != "assistant":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionCallContent) and content.name == tool_name:
|
||||
if content.type == "function_call" and content.name == tool_name:
|
||||
tool_args = safe_json_parse(content.arguments)
|
||||
break
|
||||
if tool_args is not None:
|
||||
@@ -255,7 +252,7 @@ def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any
|
||||
return {}
|
||||
safe_metadata: dict[str, Any] = {}
|
||||
for key, value in thread_metadata.items():
|
||||
value_str = value if isinstance(value, str) else json.dumps(value)
|
||||
value_str = value if isinstance(value, str) else json.dumps(make_json_safe(value))
|
||||
if len(value_str) > 512:
|
||||
value_str = value_str[:512]
|
||||
safe_metadata[key] = value_str
|
||||
@@ -287,7 +284,7 @@ def collect_approved_state_snapshots(
|
||||
if get_role_value(msg) != "user":
|
||||
continue
|
||||
for content in msg.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if content.type == "function_approval_response":
|
||||
if not content.function_call or not content.approved:
|
||||
continue
|
||||
parsed_args = content.function_call.parse_arguments()
|
||||
@@ -319,7 +316,7 @@ def collect_approved_state_snapshots(
|
||||
return events
|
||||
|
||||
|
||||
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
|
||||
def latest_approval_response(messages: list[ChatMessage]) -> Content | None:
|
||||
"""Get the latest approval response from messages.
|
||||
|
||||
Args:
|
||||
@@ -332,12 +329,12 @@ def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalRes
|
||||
return None
|
||||
last_message = messages[-1]
|
||||
for content in last_message.contents:
|
||||
if type(content) is FunctionApprovalResponseContent:
|
||||
if content.type == "function_approval_response":
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
|
||||
def approval_steps(approval: Content) -> list[Any]:
|
||||
"""Extract steps from an approval response.
|
||||
|
||||
Args:
|
||||
@@ -346,9 +343,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
|
||||
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")
|
||||
state_args = approval.additional_properties.get("ag_ui_state_args", None)
|
||||
if isinstance(state_args, dict):
|
||||
steps = state_args.get("steps")
|
||||
if isinstance(steps, list):
|
||||
@@ -365,7 +360,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
|
||||
|
||||
|
||||
def is_step_based_approval(
|
||||
approval: FunctionApprovalResponseContent,
|
||||
approval: Content,
|
||||
predict_state_config: dict[str, dict[str, str]] | None,
|
||||
) -> bool:
|
||||
"""Check if an approval is step-based.
|
||||
|
||||
@@ -6,7 +6,9 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import CustomEvent, EventType
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage, Content
|
||||
|
||||
from .._utils import make_json_safe
|
||||
|
||||
|
||||
class StateManager:
|
||||
@@ -67,11 +69,11 @@ class StateManager:
|
||||
if conversation_has_tool_calls and not self._state_from_input:
|
||||
return None
|
||||
|
||||
state_json = json.dumps(self.current_state, indent=2)
|
||||
state_json = json.dumps(make_json_safe(self.current_state), indent=2)
|
||||
return ChatMessage(
|
||||
role="system",
|
||||
contents=[
|
||||
TextContent(
|
||||
Content.from_text(
|
||||
text=(
|
||||
"Current state of the application:\n"
|
||||
f"{state_json}\n\n"
|
||||
|
||||
@@ -25,13 +25,11 @@ from agent_framework import (
|
||||
AgentProtocol,
|
||||
AgentThread,
|
||||
ChatAgent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
)
|
||||
from agent_framework._middleware import extract_and_merge_function_middleware
|
||||
from agent_framework._tools import (
|
||||
FunctionInvocationConfiguration,
|
||||
_collect_approval_responses, # type: ignore
|
||||
_replace_approval_contents_with_results, # type: ignore
|
||||
_try_execute_function_calls, # type: ignore
|
||||
@@ -245,7 +243,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
|
||||
if not msg:
|
||||
return False
|
||||
|
||||
return bool(msg.additional_properties.get("is_tool_result", False))
|
||||
return bool((msg.additional_properties or {}).get("is_tool_result", False))
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -285,12 +283,12 @@ class HumanInTheLoopOrchestrator(Orchestrator):
|
||||
last_message = context.last_message
|
||||
if last_message:
|
||||
for content in last_message.contents:
|
||||
if isinstance(content, TextContent):
|
||||
if content.type == "text":
|
||||
tool_content_text = content.text
|
||||
break
|
||||
|
||||
try:
|
||||
tool_result = json.loads(tool_content_text)
|
||||
tool_result = json.loads(tool_content_text) # type: ignore[arg-type]
|
||||
accepted = tool_result.get("accepted", False)
|
||||
steps = tool_result.get("steps", [])
|
||||
|
||||
@@ -328,7 +326,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse tool result: {tool_content_text}")
|
||||
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}")
|
||||
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}") # type: ignore[index]
|
||||
yield event_bridge.create_run_finished_event()
|
||||
|
||||
|
||||
@@ -390,7 +388,7 @@ class DefaultOrchestrator(Orchestrator):
|
||||
|
||||
response_format = None
|
||||
if isinstance(context.agent, ChatAgent):
|
||||
response_format = context.agent.default_options.get("response_format")
|
||||
response_format = (context.agent.default_options or {}).get("response_format")
|
||||
skip_text_content = response_format is not None
|
||||
|
||||
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
|
||||
@@ -441,25 +439,24 @@ class DefaultOrchestrator(Orchestrator):
|
||||
logger.info(f" Message {i}: role={role}, id={msg_id}")
|
||||
if hasattr(msg, "contents") and msg.contents:
|
||||
for j, content in enumerate(msg.contents):
|
||||
content_type = type(content).__name__
|
||||
if isinstance(content, TextContent):
|
||||
logger.debug(" Content %s: %s - text_length=%s", j, content_type, len(content.text))
|
||||
elif isinstance(content, FunctionCallContent):
|
||||
if content.type == "text":
|
||||
logger.debug(" Content %s: %s - text_length=%s", j, content.type, len(content.text)) # type: ignore[arg-type]
|
||||
elif content.type == "function_call":
|
||||
arg_length = len(str(content.arguments)) if content.arguments else 0
|
||||
logger.debug(
|
||||
" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length
|
||||
" Content %s: %s - %s args_length=%s", j, content.type, content.name, arg_length
|
||||
)
|
||||
elif isinstance(content, FunctionResultContent):
|
||||
elif content.type == "function_result":
|
||||
result_preview = type(content.result).__name__ if content.result is not None else "None"
|
||||
logger.debug(
|
||||
" Content %s: %s - call_id=%s, result_type=%s",
|
||||
j,
|
||||
content_type,
|
||||
content.type,
|
||||
content.call_id,
|
||||
result_preview,
|
||||
)
|
||||
else:
|
||||
logger.debug(f" Content {j}: {content_type}")
|
||||
logger.debug(f" Content {j}: {content.type}")
|
||||
|
||||
pending_tool_calls: list[dict[str, Any]] = []
|
||||
tool_calls_by_id: dict[str, dict[str, Any]] = {}
|
||||
@@ -536,16 +533,14 @@ class DefaultOrchestrator(Orchestrator):
|
||||
logger.error("Failed to execute approved tool calls; injecting error results.")
|
||||
approved_function_results = []
|
||||
|
||||
normalized_results: list[FunctionResultContent] = []
|
||||
normalized_results: list[Content] = []
|
||||
for idx, approval in enumerate(approved_responses):
|
||||
if idx < len(approved_function_results) and isinstance(
|
||||
approved_function_results[idx], FunctionResultContent
|
||||
):
|
||||
if idx < len(approved_function_results) and approved_function_results[idx].type == "function_result":
|
||||
normalized_results.append(approved_function_results[idx])
|
||||
continue
|
||||
call_id = approval.function_call.call_id or approval.id
|
||||
call_id = approval.function_call.call_id or approval.id # type: ignore[union-attr]
|
||||
normalized_results.append(
|
||||
FunctionResultContent(call_id=call_id, result="Error: Tool call invocation failed.")
|
||||
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.") # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
|
||||
@@ -661,8 +656,8 @@ class DefaultOrchestrator(Orchestrator):
|
||||
if all_updates is not None:
|
||||
all_updates.append(update)
|
||||
if event_bridge.current_message_id is None and update.contents:
|
||||
has_tool_call = any(isinstance(content, FunctionCallContent) for content in update.contents)
|
||||
has_text = any(isinstance(content, TextContent) for content in update.contents)
|
||||
has_tool_call = any(content.type == "function_call" for content in update.contents)
|
||||
has_text = any(content.type == "text" for content in update.contents)
|
||||
if has_tool_call and not has_text:
|
||||
tool_message_id = generate_event_id()
|
||||
event_bridge.current_message_id = tool_message_id
|
||||
|
||||
@@ -6,6 +6,7 @@ import sys
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from agent_framework import ChatOptions
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
from typing import TypeVar
|
||||
@@ -19,8 +20,6 @@ __all__ = [
|
||||
"RunMetadata",
|
||||
]
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PredictStateConfig(TypedDict):
|
||||
"""Configuration for predictive state updates."""
|
||||
|
||||
@@ -141,11 +141,14 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401
|
||||
if isinstance(obj, (datetime, date)):
|
||||
return obj.isoformat()
|
||||
if is_dataclass(obj):
|
||||
return asdict(obj) # type: ignore[arg-type]
|
||||
# asdict may return nested non-dataclass objects, so recursively make them safe
|
||||
return make_json_safe(asdict(obj)) # type: ignore[arg-type]
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump() # type: ignore[no-any-return]
|
||||
return make_json_safe(obj.model_dump()) # type: ignore[no-any-return]
|
||||
if hasattr(obj, "to_dict"):
|
||||
return make_json_safe(obj.to_dict()) # type: ignore[no-any-return]
|
||||
if hasattr(obj, "dict"):
|
||||
return obj.dict() # type: ignore[no-any-return]
|
||||
return make_json_safe(obj.dict()) # type: ignore[no-any-return]
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
|
||||
if isinstance(obj, (list, tuple)):
|
||||
|
||||
@@ -18,7 +18,7 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
|
||||
from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, Content, ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -221,7 +221,6 @@ class TaskStepsAgentWithExecution:
|
||||
chat_client = chat_agent.chat_client # type: ignore
|
||||
|
||||
# Build messages for summary call
|
||||
from agent_framework._types import ChatMessage, TextContent
|
||||
|
||||
original_messages = input_data.get("messages", [])
|
||||
|
||||
@@ -234,7 +233,7 @@ class TaskStepsAgentWithExecution:
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
role=msg.get("role", "user"),
|
||||
contents=[TextContent(text=content_str)],
|
||||
contents=[Content.from_text(text=content_str)],
|
||||
)
|
||||
)
|
||||
elif isinstance(msg, ChatMessage):
|
||||
@@ -245,7 +244,7 @@ class TaskStepsAgentWithExecution:
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[
|
||||
TextContent(
|
||||
Content.from_text(
|
||||
text="The steps have been successfully executed. Provide a brief one-sentence summary."
|
||||
)
|
||||
],
|
||||
|
||||
@@ -50,11 +50,9 @@ async def main():
|
||||
print("\nAssistant: ", end="", flush=True)
|
||||
|
||||
# Display text content as it streams
|
||||
from agent_framework import TextContent
|
||||
|
||||
for content in update.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
print(f"\033[96m{content.text}\033[0m", end="", flush=True)
|
||||
if hasattr(content, "text") and content.text: # type: ignore[attr-defined]
|
||||
print(f"\033[96m{content.text}\033[0m", end="", flush=True) # type: ignore[attr-defined]
|
||||
|
||||
# Display finish reason if present
|
||||
if update.finish_reason:
|
||||
|
||||
@@ -73,11 +73,9 @@ async def streaming_example(client: AGUIChatClient, thread_id: str | None = None
|
||||
if not thread_id and update.additional_properties:
|
||||
thread_id = update.additional_properties.get("thread_id")
|
||||
|
||||
from agent_framework import TextContent
|
||||
|
||||
for content in update.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
print(content.text, end="", flush=True)
|
||||
if content.type == "text" and content.text: # type: ignore[attr-defined]
|
||||
print(content.text, end="", flush=True) # type: ignore[attr-defined]
|
||||
|
||||
print("\n")
|
||||
return thread_id
|
||||
@@ -138,13 +136,11 @@ async def tool_example(client: AGUIChatClient, thread_id: str | None = None):
|
||||
print(f"Assistant: {response.text}")
|
||||
|
||||
# Show tool calls if any
|
||||
from agent_framework import FunctionCallContent
|
||||
|
||||
tool_called = False
|
||||
for message in response.messages:
|
||||
for content in message.contents:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
print(f"\n[Tool Called: {content.name}]")
|
||||
if content.type == "function_call": # type: ignore[attr-defined]
|
||||
print(f"\n[Tool Called: {content.name}]") # type: ignore[attr-defined]
|
||||
tool_called = True
|
||||
|
||||
if not tool_called:
|
||||
@@ -176,7 +172,7 @@ async def conversation_example(client: AGUIChatClient):
|
||||
|
||||
# Second turn - using same thread
|
||||
print("\nUser: What's my name?\n")
|
||||
response2 = await client.get_response("What's my name?", metadata={"thread_id": thread_id})
|
||||
response2 = await client.get_response("What's my name?", options={"metadata": {"thread_id": thread_id}})
|
||||
print(f"Assistant: {response2.text}")
|
||||
|
||||
# Check if context was maintained
|
||||
@@ -186,7 +182,7 @@ async def conversation_example(client: AGUIChatClient):
|
||||
# Third turn
|
||||
print("\nUser: Can you also tell me what 10 * 5 is?\n")
|
||||
response3 = await client.get_response(
|
||||
"Can you also tell me what 10 * 5 is?", metadata={"thread_id": thread_id}, tools=[calculate]
|
||||
"Can you also tell me what 10 * 5 is?", options={"metadata": {"thread_id": thread_id}}, tools=[calculate]
|
||||
)
|
||||
print(f"Assistant: {response3.text}")
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
|
||||
from agent_framework import ChatAgent, ai_function
|
||||
from agent_framework.ag_ui import AGUIChatClient
|
||||
|
||||
# Enable debug logging
|
||||
@@ -141,8 +141,9 @@ async def main():
|
||||
# Build from contents when no direct text
|
||||
parts: list[str] = []
|
||||
for c in getattr(m, "contents", []) or []:
|
||||
if isinstance(c, FunctionCallContent):
|
||||
args = c.arguments
|
||||
content_type = getattr(c, "type", None)
|
||||
if content_type == "function_call":
|
||||
args = getattr(c, "arguments", None)
|
||||
if isinstance(args, dict):
|
||||
try:
|
||||
import json as _json
|
||||
@@ -152,12 +153,15 @@ async def main():
|
||||
args_str = str(args)
|
||||
else:
|
||||
args_str = str(args or "{}")
|
||||
parts.append(f"tool_call {c.name} {args_str}")
|
||||
elif isinstance(c, FunctionResultContent):
|
||||
parts.append(f"tool_result[{c.call_id}]: {str(c.result)[:40]}")
|
||||
elif isinstance(c, TextContent):
|
||||
if c.text:
|
||||
parts.append(c.text)
|
||||
parts.append(f"tool_call {getattr(c, 'name', '?')} {args_str}")
|
||||
elif content_type == "function_result":
|
||||
call_id = getattr(c, "call_id", "?")
|
||||
result = getattr(c, "result", None)
|
||||
parts.append(f"tool_result[{call_id}]: {str(result)[:40]}")
|
||||
elif content_type == "text":
|
||||
text = getattr(c, "text", None)
|
||||
if text:
|
||||
parts.append(text)
|
||||
else:
|
||||
typename = getattr(c, "type", c.__class__.__name__)
|
||||
parts.append(f"<{typename}>")
|
||||
|
||||
@@ -11,14 +11,13 @@ from agent_framework import (
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
FunctionCallContent,
|
||||
Content,
|
||||
Role,
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent
|
||||
from agent_framework_ag_ui._client import AGUIChatClient
|
||||
from agent_framework_ag_ui._http_service import AGUIHttpService
|
||||
|
||||
|
||||
@@ -96,13 +95,11 @@ class TestAGUIChatClient:
|
||||
state_json = json.dumps(state_data)
|
||||
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
|
||||
|
||||
from agent_framework import DataContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="user", text="Hello"),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
|
||||
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -121,12 +118,10 @@ class TestAGUIChatClient:
|
||||
invalid_json = "not valid json"
|
||||
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
|
||||
|
||||
from agent_framework import DataContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
|
||||
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -200,8 +195,8 @@ class TestAGUIChatClient:
|
||||
|
||||
first_content = updates[1].contents[0]
|
||||
second_content = updates[2].contents[0]
|
||||
assert isinstance(first_content, TextContent)
|
||||
assert isinstance(second_content, TextContent)
|
||||
assert first_content.type == "text"
|
||||
assert second_content.type == "text"
|
||||
assert first_content.text == "Hello"
|
||||
assert second_content.text == " world"
|
||||
|
||||
@@ -294,13 +289,12 @@ class TestAGUIChatClient:
|
||||
updates.append(update)
|
||||
|
||||
function_calls = [
|
||||
content for update in updates for content in update.contents if isinstance(content, FunctionCallContent)
|
||||
content for update in updates for content in update.contents if content.type == "function_call"
|
||||
]
|
||||
assert function_calls
|
||||
assert function_calls[0].name == "get_time_zone"
|
||||
assert not any(
|
||||
isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents
|
||||
)
|
||||
|
||||
assert not any(content.type == "server_function_call" for update in updates for content in update.contents)
|
||||
|
||||
async def test_server_tool_calls_not_executed_locally(self, monkeypatch: MonkeyPatch) -> None:
|
||||
"""Server tools should not trigger local function invocation even when client tools exist."""
|
||||
@@ -343,13 +337,11 @@ class TestAGUIChatClient:
|
||||
state_json = json.dumps(state_data)
|
||||
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
|
||||
|
||||
from agent_framework import DataContent
|
||||
|
||||
messages = [
|
||||
ChatMessage(role="user", text="Hello"),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
|
||||
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, TextContent
|
||||
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content
|
||||
from pydantic import BaseModel
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
@@ -23,7 +23,7 @@ async def test_agent_initialization_basic():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent[ChatOptions](
|
||||
chat_client=StreamingChatClientStub(stream_fn),
|
||||
@@ -45,7 +45,7 @@ async def test_agent_initialization_with_state_schema():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
state_schema: dict[str, dict[str, Any]] = {"document": {"type": "string"}}
|
||||
@@ -61,7 +61,7 @@ async def test_agent_initialization_with_predict_state_config():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
|
||||
@@ -77,7 +77,7 @@ async def test_agent_initialization_with_pydantic_state_schema():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
class MyState(BaseModel):
|
||||
document: str
|
||||
@@ -100,7 +100,7 @@ async def test_run_started_event_emission():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -124,7 +124,7 @@ async def test_predict_state_custom_event_emission():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
predict_config = {
|
||||
@@ -156,7 +156,7 @@ async def test_initial_state_snapshot_with_schema():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
state_schema = {"document": {"type": "string"}}
|
||||
@@ -186,7 +186,7 @@ async def test_state_initialization_object_type():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
state_schema: dict[str, dict[str, Any]] = {"recipe": {"type": "object", "properties": {}}}
|
||||
@@ -213,7 +213,7 @@ async def test_state_initialization_array_type():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
state_schema: dict[str, dict[str, Any]] = {"steps": {"type": "array", "items": {}}}
|
||||
@@ -240,7 +240,7 @@ async def test_run_finished_event_emission():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -262,7 +262,7 @@ async def test_tool_result_confirm_changes_accepted():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Document updated")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Document updated")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(
|
||||
@@ -309,7 +309,7 @@ async def test_tool_result_confirm_changes_rejected():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -343,7 +343,7 @@ async def test_tool_result_function_approval_accepted():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -389,7 +389,7 @@ async def test_tool_result_function_approval_rejected():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -431,7 +431,7 @@ async def test_thread_metadata_tracking():
|
||||
metadata = options.get("metadata")
|
||||
if metadata:
|
||||
thread_metadata.update(metadata)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -462,7 +462,7 @@ async def test_state_context_injection():
|
||||
metadata = options.get("metadata")
|
||||
if metadata:
|
||||
thread_metadata.update(metadata)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(
|
||||
@@ -492,7 +492,7 @@ async def test_no_messages_provided():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -516,7 +516,7 @@ async def test_message_end_event_emission():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Hello world")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello world")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(agent=agent)
|
||||
@@ -602,7 +602,7 @@ async def test_suppressed_summary_with_document_state():
|
||||
async def stream_fn(
|
||||
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
|
||||
) -> AsyncIterator[ChatResponseUpdate]:
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Response")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Response")])
|
||||
|
||||
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
|
||||
wrapper = AgentFrameworkAgent(
|
||||
@@ -650,7 +650,7 @@ async def test_agent_with_use_service_thread_is_false():
|
||||
thread = kwargs.get("thread")
|
||||
request_service_thread_id = thread.service_thread_id if thread else None
|
||||
yield ChatResponseUpdate(
|
||||
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
|
||||
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
|
||||
)
|
||||
|
||||
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
|
||||
@@ -677,7 +677,7 @@ async def test_agent_with_use_service_thread_is_true():
|
||||
thread = kwargs.get("thread")
|
||||
request_service_thread_id = thread.service_thread_id if thread else None
|
||||
yield ChatResponseUpdate(
|
||||
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
|
||||
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
|
||||
)
|
||||
|
||||
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
|
||||
@@ -693,7 +693,7 @@ async def test_agent_with_use_service_thread_is_true():
|
||||
|
||||
async def test_function_approval_mode_executes_tool():
|
||||
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
|
||||
from agent_framework import FunctionResultContent, ai_function
|
||||
from agent_framework import ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
messages_received: list[Any] = []
|
||||
@@ -712,7 +712,7 @@ async def test_function_approval_mode_executes_tool():
|
||||
# Capture the messages received by the chat client
|
||||
messages_received.clear()
|
||||
messages_received.extend(messages)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Processing completed")])
|
||||
|
||||
agent = ChatAgent(
|
||||
chat_client=StreamingChatClientStub(stream_fn),
|
||||
@@ -770,7 +770,7 @@ async def test_function_approval_mode_executes_tool():
|
||||
tool_result_found = False
|
||||
for msg in messages_received:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
if content.type == "function_result":
|
||||
tool_result_found = True
|
||||
assert content.call_id == "call_get_datetime_123"
|
||||
assert content.result == "2025/12/01 12:00:00"
|
||||
@@ -784,7 +784,7 @@ async def test_function_approval_mode_executes_tool():
|
||||
|
||||
async def test_function_approval_mode_rejection():
|
||||
"""Test that function approval rejection creates a rejection response."""
|
||||
from agent_framework import FunctionResultContent, ai_function
|
||||
from agent_framework import ai_function
|
||||
from agent_framework.ag_ui import AgentFrameworkAgent
|
||||
|
||||
messages_received: list[Any] = []
|
||||
@@ -803,7 +803,7 @@ async def test_function_approval_mode_rejection():
|
||||
# Capture the messages received by the chat client
|
||||
messages_received.clear()
|
||||
messages_received.extend(messages)
|
||||
yield ChatResponseUpdate(contents=[TextContent(text="Operation cancelled")])
|
||||
yield ChatResponseUpdate(contents=[Content.from_text(text="Operation cancelled")])
|
||||
|
||||
agent = ChatAgent(
|
||||
name="test_agent",
|
||||
@@ -855,7 +855,7 @@ async def test_function_approval_mode_rejection():
|
||||
rejection_found = False
|
||||
for msg in messages_received:
|
||||
for content in msg.contents:
|
||||
if isinstance(content, FunctionResultContent):
|
||||
if content.type == "function_result":
|
||||
rejection_found = True
|
||||
assert content.call_id == "call_delete_123"
|
||||
assert content.result == "Error: Tool call invocation was rejected by user."
|
||||
|
||||
@@ -12,7 +12,7 @@ from ag_ui.core import (
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@@ -22,7 +22,7 @@ async def test_tool_call_flow():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# Step 1: Tool call starts
|
||||
tool_call = FunctionCallContent(
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="weather-123",
|
||||
name="get_weather",
|
||||
arguments={"location": "Seattle"},
|
||||
@@ -44,7 +44,7 @@ async def test_tool_call_flow():
|
||||
assert "Seattle" in args_event.delta
|
||||
|
||||
# Step 2: Tool result comes back
|
||||
tool_result = FunctionResultContent(
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="weather-123",
|
||||
result="Weather in Seattle: Rainy, 52°F",
|
||||
)
|
||||
@@ -71,8 +71,8 @@ async def test_text_with_tool_call():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
|
||||
|
||||
# Agent says something then calls a tool
|
||||
text_content = TextContent(text="Let me check the weather for you.")
|
||||
tool_call = FunctionCallContent(
|
||||
text_content = Content.from_text(text="Let me check the weather for you.")
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="weather-456",
|
||||
name="get_forecast",
|
||||
arguments={"location": "San Francisco", "days": 3},
|
||||
@@ -102,9 +102,9 @@ async def test_multiple_tool_results():
|
||||
|
||||
# Multiple tool results
|
||||
results = [
|
||||
FunctionResultContent(call_id="tool-1", result="Result 1"),
|
||||
FunctionResultContent(call_id="tool-2", result="Result 2"),
|
||||
FunctionResultContent(call_id="tool-3", result="Result 3"),
|
||||
Content.from_function_result(call_id="tool-1", result="Result 1"),
|
||||
Content.from_function_result(call_id="tool-2", result="Result 2"),
|
||||
Content.from_function_result(call_id="tool-3", result="Result 3"),
|
||||
]
|
||||
|
||||
update = AgentResponseUpdate(contents=results)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"""Tests for document writer predictive state flow with confirm_changes."""
|
||||
|
||||
from ag_ui.core import EventType, StateDeltaEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent
|
||||
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@@ -21,7 +21,7 @@ async def test_streaming_document_with_state_deltas():
|
||||
)
|
||||
|
||||
# Simulate streaming tool call - first chunk with name
|
||||
tool_call_start = FunctionCallContent(
|
||||
tool_call_start = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Once',
|
||||
@@ -34,7 +34,9 @@ async def test_streaming_document_with_state_deltas():
|
||||
assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1)
|
||||
|
||||
# Second chunk - incomplete JSON, should try partial extraction
|
||||
tool_call_chunk2 = FunctionCallContent(call_id="call_123", name="write_document_local", arguments=" upon a time")
|
||||
tool_call_chunk2 = Content.from_function_call(
|
||||
call_id="call_123", name="write_document_local", arguments=" upon a time"
|
||||
)
|
||||
update2 = AgentResponseUpdate(contents=[tool_call_chunk2])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
@@ -71,7 +73,7 @@ async def test_confirm_changes_emission():
|
||||
bridge.pending_state_updates = {"document": "A short story"}
|
||||
|
||||
# Tool result
|
||||
tool_result = FunctionResultContent(
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="call_123",
|
||||
result="Document written.",
|
||||
)
|
||||
@@ -115,7 +117,7 @@ async def test_text_suppression_before_confirm():
|
||||
bridge.should_stop_after_confirm = True
|
||||
|
||||
# Text content that should be suppressed
|
||||
text = TextContent(text="I have written a story about pirates.")
|
||||
text = Content.from_text(text="I have written a story about pirates.")
|
||||
update = AgentResponseUpdate(contents=[text])
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -146,7 +148,7 @@ async def test_no_confirm_for_non_predictive_tools():
|
||||
# Different tool (not in predict_state_config)
|
||||
bridge.current_tool_call_name = "get_weather"
|
||||
|
||||
tool_result = FunctionResultContent(
|
||||
tool_result = Content.from_function_result(
|
||||
call_id="call_456",
|
||||
result="Sunny, 72°F",
|
||||
)
|
||||
@@ -175,7 +177,7 @@ async def test_state_delta_deduplication():
|
||||
)
|
||||
|
||||
# First tool call with document
|
||||
tool_call1 = FunctionCallContent(
|
||||
tool_call1 = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Same text"}',
|
||||
@@ -189,7 +191,7 @@ async def test_state_delta_deduplication():
|
||||
|
||||
# Second tool call with SAME document (shouldn't emit new delta)
|
||||
bridge.current_tool_call_name = "write_document_local"
|
||||
tool_call2 = FunctionCallContent(
|
||||
tool_call2 = Content.from_function_call(
|
||||
call_id="call_2",
|
||||
name="write_document_local",
|
||||
arguments='{"document":"Same text"}', # Identical content
|
||||
@@ -216,7 +218,7 @@ async def test_predict_state_config_multiple_fields():
|
||||
)
|
||||
|
||||
# Tool call with both fields
|
||||
tool_call = FunctionCallContent(
|
||||
tool_call = Content.from_function_call(
|
||||
call_id="call_999",
|
||||
name="create_post",
|
||||
arguments='{"title":"My Post","body":"Post content"}',
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
|
||||
from agent_framework import ChatAgent, ChatResponseUpdate, Content
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from fastapi.params import Depends
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
|
||||
|
||||
def build_chat_client(response_text: str = "Test response") -> StreamingChatClientStub:
|
||||
"""Create a typed chat client stub for endpoint tests."""
|
||||
updates = [ChatResponseUpdate(contents=[TextContent(text=response_text)])]
|
||||
updates = [ChatResponseUpdate(contents=[Content.from_text(text=response_text)])]
|
||||
return StreamingChatClientStub(stream_from_updates(updates))
|
||||
|
||||
|
||||
|
||||
@@ -6,10 +6,7 @@ import json
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
FunctionApprovalRequestContent,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
Content,
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +16,7 @@ async def test_basic_text_message_conversion():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[TextContent(text="Hello")])
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text="Hello")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
@@ -35,8 +32,8 @@ async def test_text_message_streaming():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
@@ -61,7 +58,7 @@ async def test_skip_text_content_for_structured_outputs():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True)
|
||||
|
||||
update = AgentResponseUpdate(contents=[TextContent(text='{"result": "data"}')])
|
||||
update = AgentResponseUpdate(contents=[Content.from_text(text='{"result": "data"}')])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# No events should be emitted
|
||||
@@ -74,9 +71,9 @@ async def test_skip_text_content_for_empty_text():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[TextContent(text="")]) # Empty chunk
|
||||
update3 = AgentResponseUpdate(contents=[TextContent(text="world")])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="")]) # Empty chunk
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
@@ -105,7 +102,7 @@ async def test_tool_call_with_name():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 1
|
||||
@@ -121,15 +118,17 @@ async def test_tool_call_streaming_args():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk: name only
|
||||
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Second chunk: arguments chunk 1 (name can be empty string for continuation)
|
||||
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')])
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_123", arguments='{"query": "')]
|
||||
)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Third chunk: arguments chunk 2
|
||||
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')])
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_123", arguments='AI"}')])
|
||||
events3 = await bridge.from_agent_run_update(update3)
|
||||
|
||||
# First update: ToolCallStartEvent
|
||||
@@ -167,9 +166,11 @@ async def test_streaming_tool_call_no_duplicate_start_events():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# Simulate streaming tool call: first chunk has name, subsequent chunks have name=""
|
||||
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="get_weather", call_id="call_789")])
|
||||
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='{"loc":')])
|
||||
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='"SF"}')])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="get_weather", call_id="call_789")])
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_789", arguments='{"loc":')]
|
||||
)
|
||||
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_789", arguments='"SF"}')])
|
||||
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
@@ -193,7 +194,7 @@ async def test_tool_result_with_dict():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
result_data = {"status": "success", "count": 42}
|
||||
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=result_data)])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallEndEvent + ToolCallResultEvent
|
||||
@@ -214,7 +215,7 @@ async def test_tool_result_with_string():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result="Search complete")])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
@@ -229,7 +230,7 @@ async def test_tool_result_with_none():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=None)])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
@@ -247,8 +248,8 @@ async def test_multiple_tool_results_in_sequence():
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionResultContent(call_id="call_1", result="Result 1"),
|
||||
FunctionResultContent(call_id="call_2", result="Result 2"),
|
||||
Content.from_function_result(call_id="call_1", result="Result 1"),
|
||||
Content.from_function_result(call_id="call_2", result="Result 2"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -272,12 +273,12 @@ async def test_function_approval_request_basic():
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="send_email",
|
||||
arguments={"to": "user@example.com", "subject": "Test"},
|
||||
)
|
||||
approval = FunctionApprovalRequestContent(
|
||||
approval = Content.from_function_approval_request(
|
||||
id="approval_001",
|
||||
function_call=func_call,
|
||||
)
|
||||
@@ -312,8 +313,8 @@ async def test_empty_predict_state_config():
|
||||
# Tool call with arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
|
||||
FunctionResultContent(call_id="call_1", result="Done"),
|
||||
Content.from_function_call(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
|
||||
Content.from_function_result(call_id="call_1", result="Done"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -347,8 +348,8 @@ async def test_tool_not_in_predict_state_config():
|
||||
# Different tool name
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
|
||||
FunctionResultContent(call_id="call_1", result="Results"),
|
||||
Content.from_function_call(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
|
||||
Content.from_function_result(call_id="call_1", result="Results"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -376,8 +377,8 @@ async def test_state_management_tracking():
|
||||
# Streaming tool call
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="write_doc", call_id="call_1"),
|
||||
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'),
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Hello"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
@@ -387,7 +388,7 @@ async def test_state_management_tracking():
|
||||
assert bridge.pending_state_updates["document"] == "Hello"
|
||||
|
||||
# Tool result should update current_state
|
||||
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
await bridge.from_agent_run_update(update2)
|
||||
|
||||
# current_state should be updated
|
||||
@@ -413,12 +414,12 @@ async def test_wildcard_tool_argument():
|
||||
# Complete tool call with dict arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
name="create_recipe",
|
||||
call_id="call_1",
|
||||
arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]},
|
||||
),
|
||||
FunctionResultContent(call_id="call_1", result="Created"),
|
||||
Content.from_function_result(call_id="call_1", result="Created"),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -503,14 +504,14 @@ async def test_state_snapshot_after_tool_result():
|
||||
# Tool call with streaming args
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="write_doc", call_id="call_1"),
|
||||
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
# Tool result should trigger StateSnapshotEvent
|
||||
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
events = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd
|
||||
@@ -526,12 +527,12 @@ async def test_message_id_persistence_across_chunks():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk
|
||||
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
|
||||
events1 = await bridge.from_agent_run_update(update1)
|
||||
message_id = events1[0].message_id
|
||||
|
||||
# Second chunk
|
||||
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should use same message_id
|
||||
@@ -546,14 +547,16 @@ async def test_tool_call_id_tracking():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# First chunk with name
|
||||
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")])
|
||||
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search", call_id="call_1")])
|
||||
await bridge.from_agent_run_update(update1)
|
||||
|
||||
assert bridge.current_tool_call_id == "call_1"
|
||||
assert bridge.current_tool_call_name == "search"
|
||||
|
||||
# Second chunk with args but no name
|
||||
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')])
|
||||
update2 = AgentResponseUpdate(
|
||||
contents=[Content.from_function_call(name="", call_id="call_1", arguments='{"q":"AI"}')]
|
||||
)
|
||||
events2 = await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Should still track same tool call
|
||||
@@ -576,8 +579,8 @@ async def test_tool_name_reset_after_result():
|
||||
# Tool call
|
||||
update1 = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="write_doc", call_id="call_1"),
|
||||
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
Content.from_function_call(name="write_doc", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
|
||||
]
|
||||
)
|
||||
await bridge.from_agent_run_update(update1)
|
||||
@@ -585,7 +588,7 @@ async def test_tool_name_reset_after_result():
|
||||
assert bridge.current_tool_call_name == "write_doc"
|
||||
|
||||
# Tool result with predictive state (should trigger confirm_changes and reset)
|
||||
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
|
||||
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
|
||||
await bridge.from_agent_run_update(update2)
|
||||
|
||||
# Tool name should be reset
|
||||
@@ -604,9 +607,9 @@ async def test_function_approval_with_wildcard_argument():
|
||||
},
|
||||
)
|
||||
|
||||
approval_content = FunctionApprovalRequestContent(
|
||||
approval_content = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=FunctionCallContent(
|
||||
function_call=Content.from_function_call(
|
||||
name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}'
|
||||
),
|
||||
)
|
||||
@@ -632,9 +635,11 @@ async def test_function_approval_missing_argument():
|
||||
},
|
||||
)
|
||||
|
||||
approval_content = FunctionApprovalRequestContent(
|
||||
approval_content = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'),
|
||||
function_call=Content.from_function_call(
|
||||
name="process", call_id="call_1", arguments='{"other_field": "value"}'
|
||||
),
|
||||
)
|
||||
|
||||
update = AgentResponseUpdate(contents=[approval_content])
|
||||
@@ -654,8 +659,8 @@ async def test_empty_predict_state_config_no_deltas():
|
||||
# Tool call with arguments
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="search", call_id="call_1"),
|
||||
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
Content.from_function_call(name="search", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -678,8 +683,8 @@ async def test_tool_with_no_matching_config():
|
||||
# Tool call for different tool
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="search_web", call_id="call_1"),
|
||||
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
Content.from_function_call(name="search_web", call_id="call_1"),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
|
||||
]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
@@ -696,7 +701,7 @@ async def test_tool_call_without_name_or_id():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# This should not crash but log an error
|
||||
update = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="", arguments='{"arg": "val"}')])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should emit ToolCallArgsEvent with generated ID
|
||||
@@ -717,7 +722,7 @@ async def test_state_delta_count_logging():
|
||||
for i in range(15):
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
|
||||
Content.from_function_call(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
|
||||
]
|
||||
)
|
||||
# Set the tool name to match config
|
||||
@@ -737,7 +742,7 @@ async def test_tool_result_with_empty_list():
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
|
||||
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=[])])
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
assert len(events) == 2
|
||||
@@ -760,7 +765,7 @@ async def test_tool_result_with_single_text_content():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
|
||||
contents=[Content.from_function_result(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
@@ -785,7 +790,7 @@ async def test_tool_result_with_multiple_text_contents():
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id="call_123",
|
||||
result=[MockTextContent("First result"), MockTextContent("Second result")],
|
||||
)
|
||||
@@ -812,7 +817,7 @@ async def test_tool_result_with_model_dump_objects():
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
update = AgentResponseUpdate(
|
||||
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
|
||||
contents=[Content.from_function_result(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
|
||||
)
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
@@ -820,3 +825,93 @@ async def test_tool_result_with_model_dump_objects():
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
# Should be properly serialized JSON array without double escaping
|
||||
assert events[1].content == '[{"value": 1}, {"value": 2}]'
|
||||
|
||||
|
||||
async def test_function_call_with_dataclass_arguments():
|
||||
"""Test FunctionCallContent with dataclass arguments is serialized correctly.
|
||||
|
||||
This test verifies the fix for the AG-UI JSON serialization error when
|
||||
HandoffAgentUserRequest (a dataclass) is passed as FunctionCallContent.arguments.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class TestRequest:
|
||||
field1: str
|
||||
field2: int
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# FunctionCallContent with a dataclass as arguments (not a string)
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="request_info",
|
||||
call_id="call_dataclass",
|
||||
arguments=TestRequest(field1="value", field2=42),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have ToolCallStartEvent and ToolCallArgsEvent
|
||||
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
|
||||
assert len(tool_args_events) == 1
|
||||
|
||||
# Verify the delta is valid JSON
|
||||
delta = tool_args_events[0].delta
|
||||
parsed = json.loads(delta)
|
||||
assert parsed == {"field1": "value", "field2": 42}
|
||||
|
||||
|
||||
async def test_function_call_with_nested_dataclass_arguments():
|
||||
"""Test FunctionCallContent with nested dataclass arguments is serialized correctly.
|
||||
|
||||
This test covers the scenario where HandoffAgentUserRequest contains an AgentResponse
|
||||
with nested content objects.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@dataclass
|
||||
class InnerContent:
|
||||
text: str
|
||||
|
||||
@dataclass
|
||||
class AgentResponseMock:
|
||||
contents: list[InnerContent]
|
||||
|
||||
@dataclass
|
||||
class HandoffRequest:
|
||||
agent_response: AgentResponseMock
|
||||
|
||||
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
|
||||
|
||||
# Simulate a HandoffAgentUserRequest-like structure
|
||||
update = AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
name="request_info",
|
||||
call_id="call_nested",
|
||||
arguments=HandoffRequest(
|
||||
agent_response=AgentResponseMock(contents=[InnerContent(text="Hello from agent")])
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
events = await bridge.from_agent_run_update(update)
|
||||
|
||||
# Should have ToolCallStartEvent and ToolCallArgsEvent
|
||||
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
|
||||
assert len(tool_args_events) == 1
|
||||
|
||||
# Verify the delta is valid JSON and contains nested structure
|
||||
delta = tool_args_events[0].delta
|
||||
parsed = json.loads(delta)
|
||||
assert "agent_response" in parsed
|
||||
assert parsed["agent_response"]["contents"] == [{"text": "Hello from agent"}]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""Tests for human in the loop (function approval requests)."""
|
||||
|
||||
from agent_framework import AgentResponseUpdate, FunctionApprovalRequestContent, FunctionCallContent
|
||||
from agent_framework import AgentResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
|
||||
@@ -17,12 +17,12 @@ async def test_function_approval_request_emission():
|
||||
)
|
||||
|
||||
# Create approval request
|
||||
func_call = FunctionCallContent(
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="send_email",
|
||||
arguments={"to": "user@example.com", "subject": "Test"},
|
||||
)
|
||||
approval_request = FunctionApprovalRequestContent(
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_001",
|
||||
function_call=func_call,
|
||||
)
|
||||
@@ -56,12 +56,12 @@ async def test_function_approval_request_with_confirm_changes():
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_456",
|
||||
name="delete_file",
|
||||
arguments={"path": "/tmp/test.txt"},
|
||||
)
|
||||
approval_request = FunctionApprovalRequestContent(
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_002",
|
||||
function_call=func_call,
|
||||
)
|
||||
@@ -109,22 +109,22 @@ async def test_multiple_approval_requests():
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
func_call_1 = FunctionCallContent(
|
||||
func_call_1 = Content.from_function_call(
|
||||
call_id="call_1",
|
||||
name="create_event",
|
||||
arguments={"title": "Meeting"},
|
||||
)
|
||||
approval_1 = FunctionApprovalRequestContent(
|
||||
approval_1 = Content.from_function_approval_request(
|
||||
id="approval_1",
|
||||
function_call=func_call_1,
|
||||
)
|
||||
|
||||
func_call_2 = FunctionCallContent(
|
||||
func_call_2 = Content.from_function_call(
|
||||
call_id="call_2",
|
||||
name="book_room",
|
||||
arguments={"room": "Conference A"},
|
||||
)
|
||||
approval_2 = FunctionApprovalRequestContent(
|
||||
approval_2 = Content.from_function_approval_request(
|
||||
id="approval_2",
|
||||
function_call=func_call_2,
|
||||
)
|
||||
@@ -164,12 +164,12 @@ async def test_function_approval_request_sets_stop_flag():
|
||||
|
||||
assert bridge.should_stop_after_confirm is False
|
||||
|
||||
func_call = FunctionCallContent(
|
||||
func_call = Content.from_function_call(
|
||||
call_id="call_stop_test",
|
||||
name="get_datetime",
|
||||
arguments={},
|
||||
)
|
||||
approval_request = FunctionApprovalRequestContent(
|
||||
approval_request = Content.from_function_approval_request(
|
||||
id="approval_stop_test",
|
||||
function_call=func_call,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
|
||||
from agent_framework import ChatMessage, Content, Role
|
||||
|
||||
from agent_framework_ag_ui._message_adapters import (
|
||||
agent_framework_messages_to_agui,
|
||||
@@ -24,7 +24,7 @@ def sample_agui_message():
|
||||
@pytest.fixture
|
||||
def sample_agent_framework_message():
|
||||
"""Create a sample Agent Framework message."""
|
||||
return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123")
|
||||
return ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")], message_id="msg-123")
|
||||
|
||||
|
||||
def test_agui_to_agent_framework_basic(sample_agui_message):
|
||||
@@ -89,7 +89,7 @@ def test_agui_tool_result_to_agent_framework():
|
||||
assert message.role == Role.USER
|
||||
|
||||
assert len(message.contents) == 1
|
||||
assert isinstance(message.contents[0], TextContent)
|
||||
assert message.contents[0].type == "text"
|
||||
assert message.contents[0].text == '{"accepted": true, "steps": []}'
|
||||
|
||||
assert message.additional_properties is not None
|
||||
@@ -141,7 +141,7 @@ def test_agui_tool_approval_updates_tool_call_arguments():
|
||||
|
||||
assert len(messages) == 2
|
||||
assistant_msg = messages[0]
|
||||
func_call = next(content for content in assistant_msg.contents if isinstance(content, FunctionCallContent))
|
||||
func_call = next(content for content in assistant_msg.contents if content.type == "function_call")
|
||||
assert func_call.arguments == {
|
||||
"steps": [
|
||||
{"description": "Boil water", "status": "enabled"},
|
||||
@@ -157,11 +157,9 @@ def test_agui_tool_approval_updates_tool_call_arguments():
|
||||
]
|
||||
}
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
content for content in approval_msg.contents if content.type == "function_approval_response"
|
||||
)
|
||||
assert approval_content.function_call.parse_arguments() == {
|
||||
"steps": [
|
||||
@@ -211,12 +209,9 @@ def test_agui_tool_approval_from_confirm_changes_maps_to_function_call():
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
content for content in approval_msg.contents if content.type == "function_approval_response"
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
@@ -259,12 +254,9 @@ def test_agui_tool_approval_from_confirm_changes_falls_back_to_sibling_call():
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
content for content in approval_msg.contents if content.type == "function_approval_response"
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
@@ -315,12 +307,9 @@ def test_agui_tool_approval_from_generate_task_steps_maps_to_function_call():
|
||||
]
|
||||
|
||||
messages = agui_messages_to_agent_framework(messages_input)
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
approval_msg = messages[1]
|
||||
approval_content = next(
|
||||
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
|
||||
content for content in approval_msg.contents if content.type == "function_approval_response"
|
||||
)
|
||||
|
||||
assert approval_content.function_call.call_id == "call_tool"
|
||||
@@ -380,15 +369,14 @@ def test_agui_function_approvals():
|
||||
assert msg.role == Role.USER
|
||||
assert len(msg.contents) == 2
|
||||
|
||||
from agent_framework import FunctionApprovalResponseContent
|
||||
|
||||
assert isinstance(msg.contents[0], FunctionApprovalResponseContent)
|
||||
assert msg.contents[0].type == "function_approval_response"
|
||||
assert msg.contents[0].approved is True
|
||||
assert msg.contents[0].id == "approval-1"
|
||||
assert msg.contents[0].function_call.name == "search"
|
||||
assert msg.contents[0].function_call.call_id == "call-1"
|
||||
|
||||
assert isinstance(msg.contents[1], FunctionApprovalResponseContent)
|
||||
assert msg.contents[1].type == "function_approval_response"
|
||||
assert msg.contents[1].id == "approval-2"
|
||||
assert msg.contents[1].approved is False
|
||||
|
||||
|
||||
@@ -406,7 +394,7 @@ def test_agui_non_string_content():
|
||||
|
||||
assert len(messages) == 1
|
||||
assert len(messages[0].contents) == 1
|
||||
assert isinstance(messages[0].contents[0], TextContent)
|
||||
assert messages[0].contents[0].type == "text"
|
||||
assert "nested" in messages[0].contents[0].text
|
||||
|
||||
|
||||
@@ -440,9 +428,9 @@ def test_agui_with_tool_calls_to_agent_framework():
|
||||
assert msg.role == Role.ASSISTANT
|
||||
assert msg.message_id == "msg-789"
|
||||
# First content is text, second is the function call
|
||||
assert isinstance(msg.contents[0], TextContent)
|
||||
assert msg.contents[0].type == "text"
|
||||
assert msg.contents[0].text == "Calling tool"
|
||||
assert isinstance(msg.contents[1], FunctionCallContent)
|
||||
assert msg.contents[1].type == "function_call"
|
||||
assert msg.contents[1].call_id == "call-123"
|
||||
assert msg.contents[1].name == "get_weather"
|
||||
assert msg.contents[1].arguments == {"location": "Seattle"}
|
||||
@@ -453,8 +441,8 @@ def test_agent_framework_to_agui_with_tool_calls():
|
||||
msg = ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[
|
||||
TextContent(text="Calling tool"),
|
||||
FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}),
|
||||
Content.from_text(text="Calling tool"),
|
||||
Content.from_function_call(call_id="call-123", name="search", arguments={"query": "test"}),
|
||||
],
|
||||
message_id="msg-456",
|
||||
)
|
||||
@@ -477,7 +465,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
|
||||
"""Test concatenating multiple text contents."""
|
||||
msg = ChatMessage(
|
||||
role=Role.ASSISTANT,
|
||||
contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")],
|
||||
contents=[Content.from_text(text="Part 1 "), Content.from_text(text="Part 2")],
|
||||
)
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
@@ -488,7 +476,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
|
||||
|
||||
def test_agent_framework_to_agui_no_message_id():
|
||||
"""Test message without message_id - should auto-generate ID."""
|
||||
msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
|
||||
msg = ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
@@ -500,7 +488,7 @@ def test_agent_framework_to_agui_no_message_id():
|
||||
|
||||
def test_agent_framework_to_agui_system_role():
|
||||
"""Test system role conversion."""
|
||||
msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")])
|
||||
msg = ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="System")])
|
||||
|
||||
messages = agent_framework_messages_to_agui([msg])
|
||||
|
||||
@@ -510,7 +498,7 @@ def test_agent_framework_to_agui_system_role():
|
||||
|
||||
def test_extract_text_from_contents():
|
||||
"""Test extracting text from contents list."""
|
||||
contents = [TextContent(text="Hello "), TextContent(text="World")]
|
||||
contents = [Content.from_text(text="Hello "), Content.from_text(text="World")]
|
||||
|
||||
result = extract_text_from_contents(contents)
|
||||
|
||||
@@ -533,7 +521,7 @@ class CustomTextContent:
|
||||
|
||||
def test_extract_text_from_custom_contents():
|
||||
"""Test extracting text from custom content objects."""
|
||||
contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")]
|
||||
contents = [CustomTextContent(text="Custom "), Content.from_text(text="Mixed")]
|
||||
|
||||
result = extract_text_from_contents(contents)
|
||||
|
||||
@@ -547,7 +535,7 @@ def test_agent_framework_to_agui_function_result_dict():
|
||||
"""Test converting FunctionResultContent with dict result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
|
||||
contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
@@ -564,7 +552,7 @@ def test_agent_framework_to_agui_function_result_none():
|
||||
"""Test converting FunctionResultContent with None result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=None)],
|
||||
contents=[Content.from_function_result(call_id="call-123", result=None)],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
@@ -580,7 +568,7 @@ def test_agent_framework_to_agui_function_result_string():
|
||||
"""Test converting FunctionResultContent with string result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
|
||||
contents=[Content.from_function_result(call_id="call-123", result="plain text result")],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
@@ -595,7 +583,7 @@ def test_agent_framework_to_agui_function_result_empty_list():
|
||||
"""Test converting FunctionResultContent with empty list result to AG-UI."""
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=[])],
|
||||
contents=[Content.from_function_result(call_id="call-123", result=[])],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
@@ -617,7 +605,7 @@ def test_agent_framework_to_agui_function_result_single_text_content():
|
||||
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
|
||||
contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
|
||||
message_id="msg-789",
|
||||
)
|
||||
|
||||
@@ -640,7 +628,7 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
|
||||
msg = ChatMessage(
|
||||
role=Role.TOOL,
|
||||
contents=[
|
||||
FunctionResultContent(
|
||||
Content.from_function_result(
|
||||
call_id="call-123",
|
||||
result=[MockTextContent("First result"), MockTextContent("Second result")],
|
||||
)
|
||||
@@ -654,3 +642,51 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
|
||||
agui_msg = messages[0]
|
||||
# Multiple items should return JSON array
|
||||
assert agui_msg["content"] == '["First result", "Second result"]'
|
||||
|
||||
|
||||
def test_agui_tool_approval_with_dataclass_modified_args():
|
||||
"""Test that agui_messages_to_agent_framework handles dataclass in modified args.
|
||||
|
||||
This tests the fix for json.dumps() serialization errors at line 274
|
||||
when modified_args contains non-serializable objects via make_json_safe.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ModifiedData:
|
||||
field1: str
|
||||
field2: int
|
||||
|
||||
# Create AG-UI format messages that simulate tool approval flow
|
||||
# where modified args could contain a dataclass after parsing
|
||||
|
||||
# First, an assistant message with a tool call (string arguments)
|
||||
assistant_msg = {
|
||||
"id": "msg-1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-test",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_state",
|
||||
"arguments": '{"data": "original"}', # String args
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Then a user approval message (the approval path will merge modified args)
|
||||
approval_msg = {
|
||||
"id": "msg-2",
|
||||
"role": "user",
|
||||
"content": '{"approved": true}',
|
||||
"toolCallId": "call-test",
|
||||
}
|
||||
|
||||
# This should NOT raise TypeError
|
||||
result = agui_messages_to_agent_framework([assistant_msg, approval_msg])
|
||||
|
||||
# Should have processed both messages without error
|
||||
assert len(result) == 2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
from agent_framework import ChatMessage, Content
|
||||
|
||||
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_123",
|
||||
arguments='{"changes": "test"}',
|
||||
@@ -19,7 +19,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text='{"accepted": true}')],
|
||||
contents=[Content.from_text(text='{"accepted": true}')],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -37,11 +37,11 @@ def test_deduplicate_messages_prefers_non_empty_tool_results() -> None:
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call1", result="")],
|
||||
contents=[Content.from_function_result(call_id="call1", result="")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call1", result="result data")],
|
||||
contents=[Content.from_function_result(call_id="call1", result="result data")],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ from agent_framework import (
|
||||
BaseChatClient,
|
||||
ChatAgent,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
|
||||
@@ -79,11 +79,11 @@ def _create_mock_chat_agent(
|
||||
if capture_messages is not None:
|
||||
capture_messages.extend(messages)
|
||||
yield AgentResponseUpdate(
|
||||
contents=[TextContent(text="ok")],
|
||||
contents=[Content.from_text(text="ok")],
|
||||
role="assistant",
|
||||
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
raw_representation=ChatResponseUpdate(
|
||||
contents=[TextContent(text="ok")],
|
||||
contents=[Content.from_text(text="ok")],
|
||||
conversation_id=thread.metadata.get("ag_ui_thread_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
|
||||
),
|
||||
@@ -253,7 +253,7 @@ async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
if content.type == "text" and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert state_messages
|
||||
assert "Vegetarian" in state_messages[0]
|
||||
@@ -302,6 +302,6 @@ async def test_state_context_not_injected_when_tool_call_matches_state() -> None
|
||||
if role_value != "system":
|
||||
continue
|
||||
for content in msg.contents or []:
|
||||
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
|
||||
if content.type == "text" and content.text.startswith("Current state of the application:"):
|
||||
state_messages.append(content.text)
|
||||
assert not state_messages
|
||||
|
||||
@@ -8,12 +8,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
ChatMessage,
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
from agent_framework import AgentResponseUpdate, ChatMessage, Content, ai_function
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentConfig
|
||||
@@ -48,14 +43,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[TextContent(text="not valid json {")],
|
||||
contents=[Content.from_text(text="not valid json {")],
|
||||
additional_properties={"is_tool_result": True},
|
||||
)
|
||||
]
|
||||
|
||||
agent = StubAgent(
|
||||
default_options={"tools": [approval_tool], "response_format": None},
|
||||
updates=[AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")],
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")],
|
||||
)
|
||||
context = TestExecutionContext(
|
||||
input_data=input_data,
|
||||
@@ -78,14 +73,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
|
||||
|
||||
async def test_sanitize_tool_history_confirm_changes() -> None:
|
||||
"""Test sanitize_tool_history logic for confirm_changes synthetic result."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
# Create messages that will trigger confirm_changes synthetic result injection
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_123",
|
||||
arguments='{"changes": "test"}',
|
||||
@@ -94,7 +89,7 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text='{"accepted": true}')],
|
||||
contents=[Content.from_text(text='{"accepted": true}')],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -134,17 +129,17 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
|
||||
|
||||
async def test_sanitize_tool_history_orphaned_tool_result() -> None:
|
||||
"""Test sanitize_tool_history removes orphaned tool results."""
|
||||
from agent_framework import ChatMessage, FunctionResultContent, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
# Tool result without preceding assistant tool call
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")],
|
||||
contents=[Content.from_function_result(call_id="orphan_123", result="orphaned data")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Hello")],
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -214,20 +209,20 @@ async def test_orphaned_tool_result_sanitization() -> None:
|
||||
|
||||
async def test_deduplicate_messages_empty_tool_results() -> None:
|
||||
"""Test deduplicate_messages prefers non-empty tool results."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")],
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_789", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_789", result="")],
|
||||
contents=[Content.from_function_result(call_id="call_789", result="")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_789", result="real data")],
|
||||
contents=[Content.from_function_result(call_id="call_789", result="real data")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -259,20 +254,20 @@ async def test_deduplicate_messages_empty_tool_results() -> None:
|
||||
|
||||
async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
|
||||
"""Test deduplicate_messages removes duplicate assistant tool call messages."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_abc", result="result")],
|
||||
contents=[Content.from_function_result(call_id="call_abc", result="result")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -303,20 +298,20 @@ async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
|
||||
|
||||
async def test_deduplicate_messages_duplicate_system_messages() -> None:
|
||||
"""Test that deduplication logic is invoked for system messages."""
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[TextContent(text="You are a helpful assistant.")],
|
||||
contents=[Content.from_text(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="system",
|
||||
contents=[TextContent(text="You are a helpful assistant.")],
|
||||
contents=[Content.from_text(text="You are a helpful assistant.")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Hello")],
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -387,20 +382,20 @@ async def test_state_context_injection() -> None:
|
||||
|
||||
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
|
||||
"""Test state context is injected when state is provided, even with tool calls."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")],
|
||||
contents=[Content.from_function_call(name="get_weather", call_id="call_xyz", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_xyz", result="sunny")],
|
||||
contents=[Content.from_function_result(call_id="call_xyz", result="sunny")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="Thanks")],
|
||||
contents=[Content.from_text(text="Thanks")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -452,7 +447,7 @@ async def test_structured_output_processing() -> None:
|
||||
default_options=DEFAULT_OPTIONS,
|
||||
updates=[
|
||||
AgentResponseUpdate(
|
||||
contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
|
||||
contents=[Content.from_text(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
|
||||
role="assistant",
|
||||
)
|
||||
],
|
||||
@@ -641,13 +636,13 @@ async def test_all_messages_filtered_handling() -> None:
|
||||
|
||||
async def test_confirm_changes_with_invalid_json_fallback() -> None:
|
||||
"""Test confirm_changes with invalid JSON falls back to normal processing."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
name="confirm_changes",
|
||||
call_id="call_confirm_invalid",
|
||||
arguments='{"changes": "test"}',
|
||||
@@ -656,7 +651,7 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
|
||||
),
|
||||
ChatMessage(
|
||||
role="user",
|
||||
contents=[TextContent(text="invalid json {")],
|
||||
contents=[Content.from_text(text="invalid json {")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -688,19 +683,18 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
|
||||
async def test_confirm_changes_closes_active_message_before_finish() -> None:
|
||||
"""Confirm-changes flow closes any active text message before run finishes."""
|
||||
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
|
||||
from agent_framework import FunctionCallContent, FunctionResultContent
|
||||
|
||||
updates = [
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
FunctionCallContent(
|
||||
Content.from_function_call(
|
||||
name="write_document_local",
|
||||
call_id="call_1",
|
||||
arguments='{"document": "Draft"}',
|
||||
)
|
||||
]
|
||||
),
|
||||
AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]),
|
||||
AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")]),
|
||||
]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
@@ -735,16 +729,16 @@ async def test_confirm_changes_closes_active_message_before_finish() -> None:
|
||||
|
||||
async def test_tool_result_kept_when_call_id_matches() -> None:
|
||||
"""Test tool result is kept when call_id matches pending tool calls."""
|
||||
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")],
|
||||
contents=[Content.from_function_call(name="get_data", call_id="call_match", arguments="{}")],
|
||||
),
|
||||
ChatMessage(
|
||||
role="tool",
|
||||
contents=[FunctionResultContent(call_id="call_match", result="data")],
|
||||
contents=[Content.from_function_result(call_id="call_match", result="data")],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -794,11 +788,11 @@ async def test_agent_protocol_fallback_paths() -> None:
|
||||
**kwargs: Any,
|
||||
) -> AsyncGenerator[AgentResponseUpdate, None]:
|
||||
self.messages_received = messages
|
||||
yield AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")
|
||||
yield AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")
|
||||
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
@@ -820,9 +814,9 @@ async def test_agent_protocol_fallback_paths() -> None:
|
||||
|
||||
async def test_initial_state_snapshot_with_array_schema() -> None:
|
||||
"""Test state initialization with array type schema."""
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": [], "state": {}}
|
||||
@@ -851,9 +845,9 @@ async def test_response_format_skip_text_content() -> None:
|
||||
class OutputModel(BaseModel):
|
||||
result: str
|
||||
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
|
||||
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
|
||||
|
||||
orchestrator = DefaultOrchestrator()
|
||||
input_data: dict[str, Any] = {"messages": []}
|
||||
@@ -876,3 +870,60 @@ async def test_response_format_skip_text_content() -> None:
|
||||
|
||||
# Test passes if no errors occur - verifies response_format code path
|
||||
assert len(events) > 0
|
||||
|
||||
|
||||
async def test_human_in_the_loop_handles_none_additional_properties() -> None:
|
||||
"""Test that HumanInTheLoopOrchestrator handles None additional_properties gracefully.
|
||||
|
||||
This test ensures the null safety fix for msg.additional_properties.get() works.
|
||||
"""
|
||||
orchestrator = HumanInTheLoopOrchestrator()
|
||||
|
||||
# Create a message with None additional_properties
|
||||
msg = ChatMessage(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
)
|
||||
# Explicitly set additional_properties to None
|
||||
msg.additional_properties = None # type: ignore[assignment]
|
||||
|
||||
agent = StubAgent() # Use default StubAgent
|
||||
config = AgentConfig()
|
||||
context = TestExecutionContext(
|
||||
input_data={"messages": [{"role": "user", "content": "Hello"}]},
|
||||
agent=agent,
|
||||
config=config,
|
||||
)
|
||||
context.set_messages([msg])
|
||||
|
||||
# can_handle should return False (not crash) when additional_properties is None
|
||||
result = orchestrator.can_handle(context)
|
||||
assert result is False
|
||||
|
||||
|
||||
async def test_default_orchestrator_handles_none_default_options() -> None:
|
||||
"""Test that DefaultOrchestrator handles None default_options gracefully.
|
||||
|
||||
This test ensures the null safety fix for context.agent.default_options.get() works.
|
||||
"""
|
||||
orchestrator = DefaultOrchestrator()
|
||||
|
||||
# Use StubAgent with default_options set to None
|
||||
agent = StubAgent(default_options=None)
|
||||
config = AgentConfig()
|
||||
context = TestExecutionContext(
|
||||
input_data={"messages": [{"role": "user", "content": "Hello"}]},
|
||||
agent=agent,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# This should NOT crash when accessing default_options.get()
|
||||
events: list[Any] = []
|
||||
async for event in orchestrator.run(context):
|
||||
events.append(event)
|
||||
# Just check a few events to verify it's working
|
||||
if len(events) > 3:
|
||||
break
|
||||
|
||||
# Test passes if no AttributeError occurred
|
||||
assert True
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ag_ui.core import RunFinishedEvent, RunStartedEvent
|
||||
from agent_framework import TextContent
|
||||
from agent_framework import Content
|
||||
from agent_framework._types import AgentResponseUpdate, ChatResponseUpdate
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
@@ -20,10 +20,10 @@ async def test_service_thread_id_when_there_are_updates():
|
||||
|
||||
updates: list[AgentResponseUpdate] = [
|
||||
AgentResponseUpdate(
|
||||
contents=[TextContent(text="Hello, user!")],
|
||||
contents=[Content.from_text(text="Hello, user!")],
|
||||
response_id="resp_67890",
|
||||
raw_representation=ChatResponseUpdate(
|
||||
contents=[TextContent(text="Hello, user!")],
|
||||
contents=[Content.from_text(text="Hello, user!")],
|
||||
conversation_id="conv_12345",
|
||||
response_id="resp_67890",
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
|
||||
from agent_framework import ChatAgent, ChatResponseUpdate, Content
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentFrameworkAgent
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
|
||||
@pytest.fixture
|
||||
def mock_agent() -> ChatAgent:
|
||||
"""Create a mock agent for testing."""
|
||||
updates = [ChatResponseUpdate(contents=[TextContent(text="Hello!")])]
|
||||
updates = [ChatResponseUpdate(contents=[Content.from_text(text="Hello!")])]
|
||||
chat_client = StreamingChatClientStub(stream_from_updates(updates))
|
||||
return ChatAgent(name="test_agent", instructions="Test agent", chat_client=chat_client)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from ag_ui.core import CustomEvent, EventType
|
||||
from agent_framework import ChatMessage, TextContent
|
||||
from agent_framework import ChatMessage
|
||||
|
||||
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
|
||||
from agent_framework_ag_ui._orchestration._state_manager import StateManager
|
||||
@@ -47,5 +47,59 @@ def test_state_context_only_when_new_user_turn() -> None:
|
||||
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
assert isinstance(message, ChatMessage)
|
||||
assert isinstance(message.contents[0], TextContent)
|
||||
assert message.contents[0].type == "text"
|
||||
assert "Current state of the application" in message.contents[0].text
|
||||
|
||||
|
||||
def test_state_manager_with_dataclass_in_state() -> None:
|
||||
"""Test that state containing dataclasses can be serialized without crashing.
|
||||
|
||||
This test ensures the fix for JSON serialization errors when state
|
||||
contains dataclass or other non-JSON-serializable objects.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
state_manager = StateManager(
|
||||
state_schema={"user": {"type": "object"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
# Initialize with a dataclass object in the state
|
||||
state_manager.initialize({"user": UserData(name="Alice", age=30)})
|
||||
|
||||
# This should NOT raise TypeError when generating the context message
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
|
||||
assert message is not None
|
||||
assert isinstance(message, ChatMessage)
|
||||
# The dataclass should be serialized to JSON in the message
|
||||
assert "Alice" in message.contents[0].text
|
||||
assert "30" in message.contents[0].text
|
||||
|
||||
|
||||
def test_state_manager_with_pydantic_in_state() -> None:
|
||||
"""Test that state containing Pydantic models can be serialized without crashing."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class UserModel(BaseModel):
|
||||
email: str
|
||||
active: bool
|
||||
|
||||
state_manager = StateManager(
|
||||
state_schema={"user": {"type": "object"}},
|
||||
predict_state_config=None,
|
||||
require_confirmation=True,
|
||||
)
|
||||
# Initialize with a Pydantic model in the state
|
||||
state_manager.initialize({"user": UserModel(email="test@example.com", active=True)})
|
||||
|
||||
# This should NOT raise TypeError
|
||||
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
|
||||
|
||||
assert message is not None
|
||||
assert "test@example.com" in message.contents[0].text
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user