-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathBlogWorkflow.cs
More file actions
135 lines (115 loc) · 5.95 KB
/
Copy pathBlogWorkflow.cs
File metadata and controls
135 lines (115 loc) · 5.95 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
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.Logging;
namespace BlogWriter;
/// <summary>
/// Orchestrates the blog-writing pipeline with workflow stages for planning,
/// research, drafting, and review.
///
/// Execution flows Blogger → Researcher → Author → Reviewer, with a bounded
/// reviewer-to-author revision loop controlled by
/// <see cref="ResearchState.MaxRevisions"/>.
/// </summary>
public class BlogWorkflow(
IBloggerAgent blogger,
IResearcherAgent researcher,
IAuthorAgent author,
IReviewerAgent reviewer,
ILogger<BlogWorkflow> logger) : IBlogWorkflow
{
// Emits the root span for a workflow run. Activated by the ActivityListener
// registered in Program.cs (or an OpenTelemetry TracerProvider).
private static readonly ActivitySource s_activitySource = new("BlogWriter.Workflow");
private static long s_operationVersion;
public async Task<ResearchState> RunAsync(
ResearchState state,
CancellationToken cancellationToken = default,
IProgress<WorkflowOutputUpdate>? output = null)
{
long operationVersion = Interlocked.Increment(ref s_operationVersion);
var publisher = new WorkflowOutputPublisher(output, operationVersion);
publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, "Writing workflow started.");
using Activity? activity = s_activitySource.StartActivity("Workflow.Run");
activity?.SetTag("blog.topic", state.MainTask);
var bloggerExecutor = new BloggerExecutor(blogger);
var researcherExecutor = new ResearcherExecutor(researcher);
var authorExecutor = new AuthorExecutor(author);
var reviewerExecutor = new ReviewerExecutor(reviewer, publisher);
Workflow workflow = new WorkflowBuilder(bloggerExecutor)
.AddEdge(bloggerExecutor, researcherExecutor)
.AddEdge(researcherExecutor, authorExecutor)
// Review only the initial author pass. A rejected review can route back
// to the author once, but the capped revision is terminal output.
.AddEdge<ResearchState>(authorExecutor, reviewerExecutor,
condition: s => s is not null && s.RevisionNumber < ResearchState.MaxRevisions)
// Bounded revision loop: route back to the author only while the draft
// still needs work and the revision cap has not been reached.
.AddEdge<ResearchState>(reviewerExecutor, authorExecutor, condition: s => s?.NeedsRevision == true)
.WithOutputFrom(reviewerExecutor, authorExecutor)
.Build();
// Stream execution instead of running to completion in one shot. The
// topology is proven terminating; streaming simply surfaces each
// executor's lifecycle as it happens, giving live progress. The final
// ResearchState is captured from the WorkflowOutputEvent emitted by the
// reviewer after approval or the author after the single revision.
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, state, cancellationToken: cancellationToken);
ResearchState? result = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().WithCancellation(cancellationToken))
{
switch (evt)
{
case ExecutorInvokedEvent invoked:
logger.LogInformation("[workflow] -> {ExecutorId} started", invoked.ExecutorId);
publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, $"{invoked.ExecutorId} started.");
break;
case ExecutorCompletedEvent completed:
logger.LogInformation("[workflow] {ExecutorId} completed", completed.ExecutorId);
publisher.PublishLifecycle(WorkflowOutputOutcome.Progress, $"{completed.ExecutorId} completed.");
break;
case ExecutorFailedEvent failed:
logger.LogError(failed.Data as Exception, "[workflow] {ExecutorId} failed", failed.ExecutorId);
publisher.PublishLifecycle(WorkflowOutputOutcome.Failure, $"{failed.ExecutorId} failed.");
// A token-cap breach must abort the whole run, not just the
// node. Re-throw it so it unwinds to the application entry point.
if (failed.Data is Exception ex && FindTokenCap(ex) is { } capEx)
{
throw capEx;
}
throw failed.Data as Exception ??
new InvalidOperationException($"Workflow executor '{failed.ExecutorId}' failed.");
case WorkflowOutputEvent { Data: ResearchState finalState }:
// Reviewer approval or the capped author revision yields the final state.
result = finalState;
publisher.PublishLifecycle(WorkflowOutputOutcome.Success, "Writing workflow completed.");
break;
}
}
// Fall back to the input state only if no output event was ever produced.
return result ?? state;
}
// Walks the exception chain (including AggregateException children) looking
// for a token-cap breach, which the workflow runtime may have wrapped.
private static TokenCapExceededException? FindTokenCap(Exception? exception)
{
while (exception is not null)
{
if (exception is TokenCapExceededException capEx)
{
return capEx;
}
if (exception is AggregateException aggregate)
{
foreach (Exception inner in aggregate.InnerExceptions)
{
if (FindTokenCap(inner) is { } found)
{
return found;
}
}
return null;
}
exception = exception.InnerException;
}
return null;
}
}