forked from cecilphillip/temporal-dotnet-agents
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
205 lines (172 loc) · 12.1 KB
/
Program.cs
File metadata and controls
205 lines (172 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// DurableChat — demonstrates multi-turn durable chat via DurableChatSessionClient,
// including tool calls (UseFunctionInvocation) and history retrieval (GetHistoryAsync).
//
// Run: dotnet run --project samples/MEAI/DurableChat/DurableChat.csproj
using System.ClientModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI;
using Temporalio.Client;
using Temporalio.Extensions.AI;
using Temporalio.Extensions.Hosting;
// ── Setup: Build the application host ────────────────────────────────────────
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.SetMinimumLevel(LogLevel.Warning);
var apiKey = builder.Configuration.GetValue<string>("OPENAI_API_KEY");
var apiBaseUrl = builder.Configuration.GetValue<string>("OPENAI_API_BASE_URL");
var model = builder.Configuration.GetValue<string>("OPENAI_MODEL") ?? "gpt-4o-mini";
var temporalAddress = builder.Configuration.GetValue<string>("TEMPORAL_ADDRESS") ?? "localhost:7233";
if (string.IsNullOrEmpty(apiBaseUrl))
throw new InvalidOperationException("OPENAI_API_BASE_URL is not configured in appsettings.json.");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("OPENAI_API_KEY is not configured. Set it with: dotnet user-secrets set \"OPENAI_API_KEY\" \"sk-...\" --project samples/MEAI/DurableChat");
// ── Setup: Connect Temporal client with DurableAIDataConverter ────────────────
// DurableAIDataConverter.Instance wraps Temporal's payload converter with
// AIJsonUtilities.DefaultOptions, which handles MEAI's $type discriminator for
// polymorphic AIContent subclasses (TextContent, FunctionCallContent, etc.).
// Without this, type information is lost when types round-trip through history.
var temporalClient = await TemporalClient.ConnectAsync(new TemporalClientConnectOptions(temporalAddress)
{
DataConverter = DurableAIDataConverter.Instance,
Namespace = "default",
});
builder.Services.AddSingleton<ITemporalClient>(temporalClient);
// ── Setup: Weather tool (used in Demo 2) ─────────────────────────────────────
static string GetCurrentWeather(string city)
=> Random.Shared.NextDouble() > 0.5
? $"It's sunny and 22 °C in {city}."
: $"It's overcast and 15 °C in {city}.";
var weatherTool = AIFunctionFactory.Create(
GetCurrentWeather,
name: "get_current_weather",
description: "Returns the current weather conditions for a given city.");
// ── Setup: Register IChatClient ───────────────────────────────────────────────
// AddChatClient is the idiomatic MEAI pattern — it returns a ChatClientBuilder
// for chaining middleware, then Build() registers the final IChatClient singleton.
// DurableChatActivities constructor-injects this on the worker side.
IChatClient openAiChatClient = new OpenAIClient(
new ApiKeyCredential(apiKey),
new OpenAIClientOptions { Endpoint = new Uri(apiBaseUrl) }
).GetChatClient(model).AsIChatClient();
builder.Services
.AddChatClient(openAiChatClient)
.UseFunctionInvocation() // handles tool call loops inside the activity
.Build();
// ── Setup: Register worker + durable AI ──────────────────────────────────────
// AddDurableAI registers DurableChatWorkflow, DurableChatActivities, and
// DurableChatSessionClient on the worker. The session client is resolved from
// DI after the host starts.
builder.Services
.AddHostedTemporalWorker("durable-chat")
.AddDurableAI(opts =>
{
opts.ActivityTimeout = TimeSpan.FromMinutes(5);
opts.SessionTimeToLive = TimeSpan.FromHours(1);
});
// ── Start ─────────────────────────────────────────────────────────────────────
var host = builder.Build();
await host.StartAsync();
Console.WriteLine("Worker started.\n");
var sessionClient = host.Services.GetRequiredService<DurableChatSessionClient>();
// ── Run demos ─────────────────────────────────────────────────────────────────
await RunMultiTurnDemoAsync(sessionClient);
await RunToolCallDemoAsync(sessionClient, weatherTool);
await RunHistoryQueryDemoAsync(sessionClient);
// ── Shutdown ──────────────────────────────────────────────────────────────────
try { await host.StopAsync(); } catch (OperationCanceledException) { }
Console.WriteLine("Done.");
// ═════════════════════════════════════════════════════════════════════════════
// Demo 1: Multi-turn conversation
//
// Shows that conversation history is preserved across turns in the Temporal
// workflow. The second question ("that city") is only answerable because the
// workflow held onto the first turn's context.
// ═════════════════════════════════════════════════════════════════════════════
static async Task RunMultiTurnDemoAsync(DurableChatSessionClient sessionClient)
{
Console.WriteLine("════════════════════════════════════════════════════════");
Console.WriteLine(" Demo 1: Multi-Turn Conversation");
Console.WriteLine("════════════════════════════════════════════════════════");
// Each conversation maps to a Temporal workflow. Reusing the same ID across
// ChatAsync calls routes all turns to the same workflow instance.
var conversationId = $"multi-turn-{Guid.NewGuid():N}";
Console.WriteLine($" Conversation ID: {conversationId}\n");
var q1 = "What is the capital of France?";
Console.WriteLine($" User : {q1}");
var r1 = await sessionClient.ChatAsync(conversationId, [new ChatMessage(ChatRole.User, q1)]);
Console.WriteLine($" Agent: {r1.Text}\n");
// The workflow's history already contains the previous exchange, so the
// model can answer this pronoun reference without being told explicitly.
var q2 = "What is the population of that city?";
Console.WriteLine($" User : {q2}");
var r2 = await sessionClient.ChatAsync(conversationId, [new ChatMessage(ChatRole.User, q2)]);
Console.WriteLine($" Agent: {r2.Text}");
Console.WriteLine("════════════════════════════════════════════════════════\n");
}
// ═════════════════════════════════════════════════════════════════════════════
// Demo 2: Tool call
//
// Shows how to expose tools to the LLM via ChatOptions.Tools. The
// UseFunctionInvocation() middleware in the pipeline handles the tool call
// loop automatically inside the Temporal activity — the whole round-trip
// (LLM request → tool call → LLM request with result) runs as one activity.
// ═════════════════════════════════════════════════════════════════════════════
static async Task RunToolCallDemoAsync(DurableChatSessionClient sessionClient, AIFunction weatherTool)
{
Console.WriteLine("════════════════════════════════════════════════════════");
Console.WriteLine(" Demo 2: Tool Call");
Console.WriteLine("════════════════════════════════════════════════════════");
var conversationId = $"tool-call-{Guid.NewGuid():N}";
Console.WriteLine($" Conversation ID: {conversationId}\n");
var q = "What is the weather like in Seattle right now?";
Console.WriteLine($" User : {q}");
// Pass tools via ChatOptions. The LLM will request a call to
// get_current_weather; UseFunctionInvocation() executes it and sends
// the result back in the same activity invocation.
var options = new ChatOptions { Tools = [weatherTool] };
var response = await sessionClient.ChatAsync(
conversationId,
[new ChatMessage(ChatRole.User, q)],
options: options);
Console.WriteLine($" Agent: {response.Text}");
Console.WriteLine("════════════════════════════════════════════════════════\n");
}
// ═════════════════════════════════════════════════════════════════════════════
// Demo 3: History query
//
// Shows that the full conversation log is persisted in the Temporal workflow
// and can be retrieved at any time via GetHistoryAsync. This includes tool
// call and tool result messages, not just user/assistant text.
// ═════════════════════════════════════════════════════════════════════════════
static async Task RunHistoryQueryDemoAsync(DurableChatSessionClient sessionClient)
{
Console.WriteLine("════════════════════════════════════════════════════════");
Console.WriteLine(" Demo 3: History Query");
Console.WriteLine("════════════════════════════════════════════════════════");
var conversationId = $"history-{Guid.NewGuid():N}";
Console.WriteLine($" Conversation ID: {conversationId}\n");
// Build up a short conversation to populate the history.
await sessionClient.ChatAsync(conversationId,
[new ChatMessage(ChatRole.User, "Name three planets in our solar system.")]);
await sessionClient.ChatAsync(conversationId,
[new ChatMessage(ChatRole.User, "Which of those is closest to the Sun?")]);
// GetHistoryAsync sends a Temporal Query to the running workflow.
// The workflow returns every ChatMessage it has accumulated — user,
// assistant, tool calls, and tool results.
var history = await sessionClient.GetHistoryAsync(conversationId);
Console.WriteLine(" Persisted history:");
foreach (var msg in history)
{
var role = msg.Role == ChatRole.User ? "User "
: msg.Role == ChatRole.Assistant ? "Agent"
: msg.Role.Value;
var text = string.Concat(msg.Contents.OfType<TextContent>().Select(c => c.Text));
if (!string.IsNullOrWhiteSpace(text))
Console.WriteLine($" [{role}] {text}");
}
Console.WriteLine($"\n Total messages stored: {history.Count}");
Console.WriteLine("════════════════════════════════════════════════════════\n");
}