-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathProgram.cs
More file actions
217 lines (191 loc) · 8.46 KB
/
Copy pathProgram.cs
File metadata and controls
217 lines (191 loc) · 8.46 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
206
207
208
209
210
211
212
213
214
215
using System.Diagnostics;
using Azure.AI.Projects;
using Azure.Identity;
using BlogWriter;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
// Secrets come from the .NET user-secrets store and from
// environment variables (secrets win on key collisions).
IConfiguration config = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets<Program>()
.Build();
string GetRequired(string key) =>
config[key] ?? throw new InvalidOperationException(
$"Missing configuration value '{key}'. Set it with: dotnet user-secrets set \"{key}\" \"<value>\"");
// Foundry project + hosted agent names — the 4 agents are pre-provisioned and
// deployed independently (see HostedAgents/*/README and azd scaffolding);
// this app only references them by name, it never creates/updates them.
var foundryProjectEndpoint = new Uri(GetRequired("FOUNDRY_PROJECT_ENDPOINT"));
string tenantId = GetRequired("AZURE_TENANT_ID");
string bloggerAgentName = config["BLOGGER_AGENT_NAME"] ?? "Blogger";
string researcherAgentName = config["RESEARCHER_AGENT_NAME"] ?? "Researcher";
string authorAgentName = config["AUTHOR_AGENT_NAME"] ?? "Author";
string reviewerAgentName = config["REVIEWER_AGENT_NAME"] ?? "Reviewer";
// Cumulative process-wide budget shared by all four MAF-hosted agent clients.
long maxTotalTokens = long.TryParse(config["MAX_TOTAL_TOKENS"], out long configuredMaxTotalTokens) ? configuredMaxTotalTokens : 40000;
// Entra ID only — no API keys, per repository constraint. Agent Framework owns
// the Foundry transport and Responses protocol details.
var azureCredential = new AzureCliCredential(new AzureCliCredentialOptions
{
TenantId = tenantId,
});
AIProjectClient projectClient = new(foundryProjectEndpoint, azureCredential);
Func<IChatClient, IChatClient> tokenCapFactory = TokenCapChatClient.CreateSharedFactory(maxTotalTokens);
AIAgent BuildFoundryAgent(string hostedAgentName)
{
Uri agentEndpoint = new($"{foundryProjectEndpoint.AbsoluteUri.TrimEnd('/')}/agents/{hostedAgentName}/endpoint/protocols/openai");
return projectClient.AsAIAgent(
agentEndpoint,
tools: null,
clientFactory: tokenCapFactory,
services: null);
}
AIAgent bloggerLlm = BuildFoundryAgent(bloggerAgentName);
AIAgent researcherLlm = BuildFoundryAgent(researcherAgentName);
AIAgent authorLlm = BuildFoundryAgent(authorAgentName);
AIAgent reviewerLlm = BuildFoundryAgent(reviewerAgentName);
// Creating a callable object
using ILoggerFactory loggerFactory = LoggerFactory.Create(builder => builder.AddConsole());
var bloggerAgent = new BloggerAgent(bloggerLlm, loggerFactory.CreateLogger<BloggerAgent>());
var researcherAgent = new ResearcherAgent(researcherLlm, loggerFactory.CreateLogger<ResearcherAgent>());
var authorAgent = new AuthorAgent(authorLlm, loggerFactory.CreateLogger<AuthorAgent>());
var reviewerAgent = new ReviewerAgent(reviewerLlm, loggerFactory.CreateLogger<ReviewerAgent>());
var app = new BlogWorkflow(bloggerAgent, researcherAgent, authorAgent, reviewerAgent, loggerFactory.CreateLogger<BlogWorkflow>());
// Distributed tracing: an ActivityListener activates every "BlogWriter.*"
// ActivitySource in the app (agents, workflow, and the IChatClient's
// "BlogWriter.ChatClient" GenAI spans) and writes span start/stop to the
// console. Swap this listener for OpenTelemetry's TracerProvider to export the
// same spans to a backend instead.
var appActivitySource = new ActivitySource("BlogWriter.Program");
ActivitySource.AddActivityListener(new ActivityListener
{
ShouldListenTo = source => source.Name.StartsWith("BlogWriter", StringComparison.Ordinal),
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
ActivityStarted = activity => Console.WriteLine($"[trace] \u2192 {activity.DisplayName}"),
ActivityStopped = activity =>
Console.WriteLine($"[trace] \u2190 {activity.DisplayName} ({activity.Duration.TotalMilliseconds:F0} ms)")
});
string sessionDirectory = config["BLOG_SESSION_STORE_PATH"]
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BlogWriter", "sessions");
IBlogSessionStore sessionStore = new FileBlogSessionStore(sessionDirectory);
// Prompts for a positive word count, re-asking until a valid value (or blank
// for the default) is entered. `minimum`, when set, enforces max >= min.
int ReadWordCount(string prompt, int defaultValue, int? minimum = null)
{
while (true)
{
Console.Write(prompt);
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
return defaultValue;
}
if (int.TryParse(input, out int value) && value > 0 && (minimum is null || value >= minimum))
{
return value;
}
Console.WriteLine(minimum is null
? "Please enter a positive whole number."
: $"Please enter a whole number greater than or equal to {minimum}.");
}
}
// Ctrl+C requests a graceful cancellation of the in-flight run instead of an
// abrupt process kill.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
while (!cts.IsCancellationRequested)
{
Console.Write("\nEnter a topic, 'resume <session-id>', or press Enter to exit: ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
break;
}
BlogSession? session = null;
const string resumePrefix = "resume ";
if (input.StartsWith(resumePrefix, StringComparison.OrdinalIgnoreCase))
{
string sessionId = input[resumePrefix.Length..].Trim();
session = await sessionStore.GetAsync(sessionId, cts.Token);
if (session is null)
{
Console.Error.WriteLine("Session not found. Check the session ID and configured session store path.");
continue;
}
}
else
{
int minWords = ReadWordCount(
$"Enter minimum word count [{ResearchState.DefaultMinWords}]: ",
ResearchState.DefaultMinWords);
int maxWords = ReadWordCount(
$"Enter maximum word count [{ResearchState.DefaultMaxWords}]: ",
ResearchState.DefaultMaxWords,
minimum: minWords);
session = await sessionStore.CreateAsync(new ResearchState
{
MainTask = input,
MinWords = minWords,
MaxWords = maxWords
}, cts.Token);
}
while (!cts.IsCancellationRequested)
{
try
{
using Activity? runActivity = appActivitySource.StartActivity("BlogWriter.Run");
runActivity?.SetTag("blog.topic", session.State.MainTask);
session.State = await app.RunAsync(session.State, cts.Token);
await sessionStore.SaveAsync(session, cts.Token);
}
catch (TokenCapExceededException ex)
{
Console.Error.WriteLine($"{ex.Message} Exiting application.");
Environment.ExitCode = 1;
return;
}
catch (OperationCanceledException)
{
Console.Error.WriteLine("Run cancelled. Exiting application.");
Environment.ExitCode = 1;
return;
}
PrintResults(session);
Console.Write("Follow-up request, or press Enter for a new topic: ");
string? followUp = Console.ReadLine();
if (string.IsNullOrWhiteSpace(followUp))
{
break;
}
session!.State.StartFollowUp(followUp);
await sessionStore.SaveAsync(session, cts.Token);
}
}
void PrintResults(BlogSession session)
{
ResearchState result = session.State;
Console.WriteLine("\n\n========== RESULTS ==========");
Console.WriteLine($"\nTask: {result.MainTask}");
Console.WriteLine($"\n\n===Research Findings===\n ({result.ResearchFindings.Count}):");
foreach (string finding in result.ResearchFindings)
{
Console.WriteLine($"- {finding}");
}
Console.WriteLine($"\n\n===Review Notes:===\n {result.ReviewNotes}");
Console.WriteLine($"\n\n===Draft:===\n{result.Draft}");
if (result.RevisionLimitReached)
{
Console.WriteLine("Note: Maximum revision limit reached; draft above printed as-is.");
}
Console.WriteLine("=============================");
Console.WriteLine($"\n\nRevision Number: {result.RevisionNumber}");
Console.WriteLine($"\nSession: {session.Id}\n");
Console.WriteLine("=============================");
}