-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathBloggerAgent.cs
More file actions
163 lines (139 loc) · 6.71 KB
/
Copy pathBloggerAgent.cs
File metadata and controls
163 lines (139 loc) · 6.71 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
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
namespace BlogWriter;
/// <summary>
/// Determines the next workflow action and subtask based on the current
/// <see cref="ResearchState"/>.
///
/// The agent applies deterministic routing rules first, then uses structured
/// model output as fallback to produce a typed <see cref="BloggerDecision"/>.
/// </summary>
public class BloggerAgent : IBloggerAgent
{
// Built once and reused. Holds the static Blogger instructions; the volatile
// state is passed per-turn as the user message.
private readonly AIAgent _agent;
// Web-style options are sufficient: BloggerDecision carries explicit
// [JsonPropertyName] attributes (next_step / task_description) that drive the
// generated schema regardless of naming policy.
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
// Emits a span per Blogger decision. Activated by the ActivityListener
// registered in Program.cs (or an OpenTelemetry TracerProvider).
private static readonly ActivitySource s_activitySource = new("BlogWriter.BloggerAgent");
private readonly ILogger<BloggerAgent> _logger;
// Per-call output-token cap, applied on each RunAsync to bound cost.
private readonly int? _maxOutputTokens;
public BloggerAgent(IChatClient llm, ChatOptions chatOptions, ILogger<BloggerAgent> logger)
{
_logger = logger;
_maxOutputTokens = chatOptions.MaxOutputTokens;
_agent = new ChatClientAgent(llm, new ChatClientAgentOptions
{
Name = "Blogger",
ChatOptions = new ChatOptions
{
Instructions = Prompts.BloggerInstructions,
Temperature = chatOptions.Temperature,
MaxOutputTokens = chatOptions.MaxOutputTokens,
},
})
.AsBuilder()
.UseOpenTelemetry(sourceName: "BlogWriter.Agents")
.Build();
_logger.LogInformation("BloggerAgent initialized.");
}
public async Task<BloggerDecision> InvokeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
using Activity? activity = s_activitySource.StartActivity("Blogger.Invoke");
activity?.SetTag("blog.revision", state.RevisionNumber);
List<string> research = state.ResearchFindings;
string researchText = research.Count > 0 ? string.Join("\n", research) : "No research yet.";
int revision = state.RevisionNumber;
bool hasResearch = research.Count > 0;
bool hasDraft = !string.IsNullOrWhiteSpace(state.Draft);
string review = state.ReviewNotes;
if (ResearchState.IsApproved(review) && hasDraft)
{
_logger.LogInformation("Blogger: Draft approved, ending workflow");
return new BloggerDecision("END", "Report approved and complete");
}
if (!hasResearch)
{
_logger.LogInformation("Blogger: No research yet, directing to researcher");
return new BloggerDecision("researcher", $"Research the topic: {state.MainTask}");
}
if (hasResearch && !hasDraft)
{
_logger.LogInformation("Blogger: Have research, creating first draft");
return new BloggerDecision("author", "Write the first draft based on research findings");
}
if (hasDraft && string.IsNullOrEmpty(review))
{
_logger.LogInformation("Blogger: Have draft, sending to reviewer");
return new BloggerDecision("reviewer", "Prepare draft for review");
}
if (!string.IsNullOrEmpty(review) && !ResearchState.IsApproved(review) && revision < ResearchState.MaxRevisions)
{
_logger.LogInformation("Blogger: Revision {Revision}, sending back to author", revision);
return new BloggerDecision("author", "Revise the draft based on review feedback");
}
// Max revisions reached
if (revision >= ResearchState.MaxRevisions)
{
_logger.LogInformation("Blogger: Max revisions reached! Ending");
return new BloggerDecision("END", "Maximum revisions reached! Finalizing report");
}
// LLM decision as fallback. The dynamic state is the user message; the
// static role lives in the agent's Instructions. MAF structured output
// hands back a typed BloggerDecision — no fenced-block cleanup, no manual
// JsonSerializer.Deserialize.
string stateSummary = $"""
Current Task: {state.MainTask}
Research Findings: {researchText}
Blog Draft: {(string.IsNullOrEmpty(state.Draft) ? "No draft yet." : state.Draft)}
Reviewer Feedback: {(string.IsNullOrEmpty(review) ? "No review yet." : review)}
Revision Number: {revision}
""";
try
{
// Cap per-call output tokens so a single turn can't blow the cost budget.
ChatClientAgentRunOptions runOptions = new(new ChatOptions
{
MaxOutputTokens = _maxOutputTokens,
});
AgentResponse<BloggerDecision> response =
await _agent.RunAsync<BloggerDecision>(stateSummary, options: runOptions, serializerOptions: _jsonOptions, cancellationToken: cancellationToken);
BloggerDecision decision = response.Result;
if (decision is not null && !string.IsNullOrEmpty(decision.NextStep))
{
return decision;
}
}
catch (TokenCapExceededException)
{
// Budget breach is fatal — let it propagate so the app can shut down.
throw;
}
catch (Exception e)
{
_logger.LogError(e, "Blogger LLM decision failed.");
}
// Final fallback - continue with author
_logger.LogInformation("Blogger: Using final fallback - continuing with author");
return new BloggerDecision("author", "Continue with draft creation");
}
/// <summary>Blogger decides the next step.</summary>
public async Task<ResearchState> BloggerNodeAsync(ResearchState state, CancellationToken cancellationToken = default)
{
BloggerDecision decision = await InvokeAsync(state, cancellationToken);
string nextStep = string.IsNullOrEmpty(decision.NextStep) ? "researcher" : decision.NextStep;
string taskDesc = string.IsNullOrEmpty(decision.TaskDescription) ? "Continue work" : decision.TaskDescription;
_logger.LogInformation("Blogger decision: {NextStep}, Task: {TaskDescription}", nextStep, taskDesc);
state.NextStep = nextStep;
state.CurrentSubTask = taskDesc;
return state;
}
}