Skip to content

Commit b0ee702

Browse files
authored
.NET: Add workflow as an agent with observability sample (microsoft#1612)
* Add workflow as an agent with observability sample * Address comment * Fix formatting * enable sensitive data * enable sensitive data for sub agents * adjust aggregator handlers
1 parent 12d17ac commit b0ee702

8 files changed

Lines changed: 297 additions & 30 deletions

File tree

dotnet/agent-framework-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
<Folder Name="/Samples/GettingStarted/Workflows/Observability/">
124124
<Project Path="samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
125125
<Project Path="samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
126+
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
126127
</Folder>
127128
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
128129
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />

dotnet/samples/GettingStarted/AgentOpenTelemetry/Program.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ static async Task<string> GetWeatherAsync([Description("The location to get the
125125
instructions: "You are a helpful assistant that provides concise and informative responses.",
126126
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
127127
.AsBuilder()
128-
.UseOpenTelemetry(SourceName) // enable telemetry at the agent level
128+
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
129129
.Build();
130130

131131
var thread = agent.GetNewThread();
@@ -134,6 +134,8 @@ static async Task<string> GetWeatherAsync([Description("The location to get the
134134

135135
// Create a parent span for the entire agent session
136136
using var sessionActivity = activitySource.StartActivity("Agent Session");
137+
Console.WriteLine($"Trace ID: {sessionActivity?.TraceId} ");
138+
137139
var sessionId = Guid.NewGuid().ToString("N");
138140
sessionActivity?
139141
.SetTag("agent.name", "OpenTelemetryDemoAgent")
@@ -147,7 +149,7 @@ static async Task<string> GetWeatherAsync([Description("The location to get the
147149

148150
while (true)
149151
{
150-
Console.Write("You: ");
152+
Console.Write("You (or 'exit' to quit): ");
151153
var userInput = Console.ReadLine();
152154

153155
if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))

dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/Program.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
using Microsoft.Agents.AI.Workflows;
77
using Microsoft.Extensions.AI;
88

9-
namespace WorkflowAsAnAgentsSample;
9+
namespace WorkflowAsAnAgentSample;
1010

1111
/// <summary>
1212
/// This sample introduces the concepts workflows as agents, where a workflow can be
@@ -61,9 +61,9 @@ static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string in
6161
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
6262
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
6363
{
64-
if (update.MessageId is null)
64+
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
6565
{
66-
// skip updates that don't have a message ID
66+
// skip updates that don't have a message ID or text
6767
continue;
6868
}
6969
Console.Clear();

dotnet/samples/GettingStarted/Workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
using Microsoft.Agents.AI.Workflows;
55
using Microsoft.Extensions.AI;
66

7-
namespace WorkflowAsAnAgentsSample;
7+
namespace WorkflowAsAnAgentSample;
88

99
internal static class WorkflowFactory
1010
{
@@ -41,44 +41,43 @@ private static ChatClientAgent GetLanguageAgent(string targetLanguage, IChatClie
4141
/// <summary>
4242
/// Executor that starts the concurrent processing by sending messages to the agents.
4343
/// </summary>
44-
private sealed class ConcurrentStartExecutor() :
45-
Executor<List<ChatMessage>>("ConcurrentStartExecutor")
44+
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
4645
{
47-
/// <summary>
48-
/// Starts the concurrent processing by sending messages to the agents.
49-
/// </summary>
50-
/// <param name="message">The user message to process</param>
51-
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
52-
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
53-
/// The default is <see cref="CancellationToken.None"/>.</param>
54-
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
46+
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
5547
{
56-
// Broadcast the message to all connected agents. Receiving agents will queue
57-
// the message but will not start processing until they receive a turn token.
58-
await context.SendMessageAsync(message, cancellationToken: cancellationToken);
59-
// Broadcast the turn token to kick off the agents.
60-
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken);
48+
return routeBuilder
49+
.AddHandler<List<ChatMessage>>(this.RouteMessages)
50+
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
51+
}
52+
53+
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
54+
{
55+
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
56+
}
57+
58+
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
59+
{
60+
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
6161
}
6262
}
6363

6464
/// <summary>
6565
/// Executor that aggregates the results from the concurrent agents.
6666
/// </summary>
67-
private sealed class ConcurrentAggregationExecutor() :
68-
Executor<ChatMessage>("ConcurrentAggregationExecutor")
67+
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
6968
{
7069
private readonly List<ChatMessage> _messages = [];
7170

7271
/// <summary>
7372
/// Handles incoming messages from the agents and aggregates their responses.
7473
/// </summary>
75-
/// <param name="message">The message from the agent</param>
74+
/// <param name="message">The messages from the agent</param>
7675
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
7776
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
7877
/// The default is <see cref="CancellationToken.None"/>.</param>
79-
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
78+
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
8079
{
81-
this._messages.Add(message);
80+
this._messages.AddRange(message);
8281

8382
if (this._messages.Count == 2)
8483
{

dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,21 +97,21 @@ public override async ValueTask HandleAsync(string message, IWorkflowContext con
9797
/// Executor that aggregates the results from the concurrent agents.
9898
/// </summary>
9999
internal sealed class ConcurrentAggregationExecutor() :
100-
Executor<ChatMessage>("ConcurrentAggregationExecutor")
100+
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
101101
{
102102
private readonly List<ChatMessage> _messages = [];
103103

104104
/// <summary>
105105
/// Handles incoming messages from the agents and aggregates their responses.
106106
/// </summary>
107-
/// <param name="message">The message from the agent</param>
107+
/// <param name="message">The messages from the agent</param>
108108
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
109109
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
110110
/// The default is <see cref="CancellationToken.None"/>.</param>
111111
/// <returns>A task representing the asynchronous operation</returns>
112-
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
112+
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
113113
{
114-
this._messages.Add(message);
114+
this._messages.AddRange(message);
115115

116116
if (this._messages.Count == 2)
117117
{
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System.Diagnostics;
4+
using Azure.AI.OpenAI;
5+
using Azure.Identity;
6+
using Azure.Monitor.OpenTelemetry.Exporter;
7+
using Microsoft.Agents.AI;
8+
using Microsoft.Agents.AI.Workflows;
9+
using Microsoft.Extensions.AI;
10+
using OpenTelemetry;
11+
using OpenTelemetry.Resources;
12+
using OpenTelemetry.Trace;
13+
14+
namespace WorkflowAsAnAgentObservabilitySample;
15+
16+
/// <summary>
17+
/// This sample shows how to enable OpenTelemetry observability for workflows when
18+
/// using them as <see cref="AIAgent"/>s.
19+
///
20+
/// In this example, we create a workflow that uses two language agents to process
21+
/// input concurrently, one that responds in French and another that responds in English.
22+
///
23+
/// You will interact with the workflow in an interactive loop, sending messages and receiving
24+
/// streaming responses from the workflow as if it were an agent who responds in both languages.
25+
///
26+
/// OpenTelemetry observability is enabled at multiple levels:
27+
/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service.
28+
/// 2. At the agent level, capturing telemetry for agent operations.
29+
/// 3. At the workflow level, capturing telemetry for workflow execution.
30+
///
31+
/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to
32+
/// Azure Monitor if an Application Insights connection string is provided.
33+
///
34+
/// Learn how to set up an Aspire dashboard here:
35+
/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash
36+
/// </summary>
37+
/// <remarks>
38+
/// Pre-requisites:
39+
/// - Foundational samples should be completed first.
40+
/// - This sample uses concurrent processing.
41+
/// - An Azure OpenAI endpoint and deployment name.
42+
/// - An Application Insights resource for telemetry (optional).
43+
/// </remarks>
44+
public static class Program
45+
{
46+
private const string SourceName = "Workflow.ApplicationInsightsSample";
47+
private static readonly ActivitySource s_activitySource = new(SourceName);
48+
49+
private static async Task Main()
50+
{
51+
// Set up observability
52+
var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
53+
var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317";
54+
55+
var resourceBuilder = ResourceBuilder
56+
.CreateDefault()
57+
.AddService("WorkflowSample");
58+
59+
var traceProviderBuilder = Sdk.CreateTracerProviderBuilder()
60+
.SetResourceBuilder(resourceBuilder)
61+
.AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry
62+
.AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry
63+
.AddSource(SourceName);
64+
65+
traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
66+
if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString))
67+
{
68+
traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString);
69+
}
70+
71+
using var traceProvider = traceProviderBuilder.Build();
72+
73+
// Set up the Azure OpenAI client
74+
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
75+
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
76+
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
77+
.GetChatClient(deploymentName)
78+
.AsIChatClient()
79+
.AsBuilder()
80+
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level
81+
.Build();
82+
83+
// Start a root activity for the application
84+
using var activity = s_activitySource.StartActivity("main");
85+
Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}");
86+
87+
// Create the workflow and turn it into an agent with OpenTelemetry instrumentation
88+
var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName);
89+
var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName)
90+
{
91+
EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses
92+
};
93+
var thread = agent.GetNewThread();
94+
95+
// Start an interactive loop to interact with the workflow as if it were an agent
96+
while (true)
97+
{
98+
Console.WriteLine();
99+
Console.Write("User (or 'exit' to quit): ");
100+
string? input = Console.ReadLine();
101+
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
102+
{
103+
break;
104+
}
105+
106+
await ProcessInputAsync(agent, thread, input);
107+
}
108+
109+
// Helper method to process user input and display streaming responses. To display
110+
// multiple interleaved responses correctly, we buffer updates by message ID and
111+
// re-render all messages on each update.
112+
static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input)
113+
{
114+
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
115+
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
116+
{
117+
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
118+
{
119+
// skip updates that don't have a message ID or text
120+
continue;
121+
}
122+
Console.Clear();
123+
124+
if (!buffer.TryGetValue(update.MessageId, out List<AgentRunResponseUpdate>? value))
125+
{
126+
value = [];
127+
buffer[update.MessageId] = value;
128+
}
129+
value.Add(update);
130+
131+
foreach (var (messageId, segments) in buffer)
132+
{
133+
string combinedText = string.Concat(segments);
134+
Console.WriteLine($"{segments[0].AuthorName}: {combinedText}");
135+
Console.WriteLine();
136+
}
137+
}
138+
}
139+
}
140+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net9.0</TargetFramework>
6+
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Azure.AI.OpenAI" />
13+
<PackageReference Include="Azure.Identity" />
14+
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
15+
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
16+
<PackageReference Include="OpenTelemetry" />
17+
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
18+
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
23+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
24+
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
25+
</ItemGroup>
26+
27+
</Project>

0 commit comments

Comments
 (0)