.NET: Update Workflow Input/Output Redesign (#881)

* feat: Make Executor id field mandatory

When checkpointing is involved, it is critical to keep executor ids consistent between runs, even when recreating a new object tree for the workflow.

The default id-setting mechanism generated a guid for part of the id, making it not work when restoring from a checkpoint.

This change prevents this situation from arising.

* feat: Enable running untyped Workflows

With the change to enable delay-instantiation of executors and support for async Executor factory methods, we must instantiate the starting executor to know what are the valid input types for the workflow.

To avoid forcing instantiation every time, and to better support workflows with multiple input types, we enable support for build and interacting with the base Workflow type without type annotations, and remove the requirement to know a valid input type when initiating a run.

* feat: Support Output from any executor and multiple outputs.
This commit is contained in:
Jacob Alber
2025-09-24 22:03:22 -04:00
committed by GitHub
Unverified
parent 03ef7f054f
commit 39e071c430
89 changed files with 1413 additions and 998 deletions
@@ -57,7 +57,8 @@ public static class Program
.AddEdge(spamDetectionExecutor, emailAssistantExecutor, condition: GetCondition(expectedResult: false))
.AddEdge(emailAssistantExecutor, sendEmailExecutor)
.AddEdge(spamDetectionExecutor, handleSpamExecutor, condition: GetCondition(expectedResult: true))
.Build<ChatMessage>();
.WithOutputFrom(handleSpamExecutor, sendEmailExecutor)
.Build();
// Read a email from a text file
string email = Resources.Read("spam.txt");
@@ -67,9 +68,9 @@ public static class Program
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is WorkflowCompletedEvent completedEvent)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"{completedEvent}");
Console.WriteLine($"{outputEvent}");
}
}
}
@@ -234,7 +235,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}"));
await context.YieldOutputAsync($"Email sent: {message.Response}");
}
/// <summary>
@@ -249,7 +250,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
{
if (message.IsSpam)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}"));
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}");
}
else
{
@@ -71,8 +71,10 @@ public static class Program
)
)
// After the email assistant writes a response, it will be sent to the send email executor
.AddEdge(emailAssistantExecutor, sendEmailExecutor);
var workflow = builder.Build<ChatMessage>();
.AddEdge(emailAssistantExecutor, sendEmailExecutor)
.WithOutputFrom(handleSpamExecutor, sendEmailExecutor, handleUncertainExecutor);
var workflow = builder.Build();
// Read a email from a text file
string email = Resources.Read("ambiguous_email.txt");
@@ -82,9 +84,9 @@ public static class Program
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is WorkflowCompletedEvent completedEvent)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"{completedEvent}");
Console.WriteLine($"{outputEvent}");
}
}
}
@@ -257,7 +259,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}"));
await context.YieldOutputAsync($"Email sent: {message.Response}").ConfigureAwait(false);
}
/// <summary>
@@ -272,7 +274,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
{
if (message.spamDecision == SpamDecision.Spam)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}"));
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}").ConfigureAwait(false);
}
else
{
@@ -294,7 +296,7 @@ internal sealed class HandleUncertainExecutor() : ReflectingExecutor<HandleUncer
if (message.spamDecision == SpamDecision.Uncertain)
{
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"));
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}");
}
else
{
@@ -80,8 +80,10 @@ public static class Program
databaseAccessExecutor,
condition: analysisResult => analysisResult?.EmailLength <= LongEmailThreshold)
// Save the analysis result to the database with summary
.AddEdge(emailSummaryExecutor, databaseAccessExecutor);
var workflow = builder.Build<ChatMessage>();
.AddEdge(emailSummaryExecutor, databaseAccessExecutor)
.WithOutputFrom(handleUncertainExecutor, handleSpamExecutor, sendEmailExecutor);
var workflow = builder.Build();
// Read a email from a text file
string email = Resources.Read("email.txt");
@@ -91,9 +93,9 @@ public static class Program
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is WorkflowCompletedEvent completedEvent)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"{completedEvent}");
Console.WriteLine($"{outputEvent}");
}
if (evt is DatabaseEvent databaseEvent)
@@ -315,7 +317,7 @@ internal sealed class SendEmailExecutor() : ReflectingExecutor<SendEmailExecutor
/// Simulate the sending of an email.
/// </summary>
public async ValueTask HandleAsync(EmailResponse message, IWorkflowContext context) =>
await context.AddEventAsync(new WorkflowCompletedEvent($"Email sent: {message.Response}"));
await context.YieldOutputAsync($"Email sent: {message.Response}");
}
/// <summary>
@@ -330,7 +332,7 @@ internal sealed class HandleSpamExecutor() : ReflectingExecutor<HandleSpamExecut
{
if (message.spamDecision == SpamDecision.Spam)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as spam: {message.Reason}"));
await context.YieldOutputAsync($"Email marked as spam: {message.Reason}");
}
else
{
@@ -352,7 +354,7 @@ internal sealed class HandleUncertainExecutor() : ReflectingExecutor<HandleUncer
if (message.spamDecision == SpamDecision.Uncertain)
{
var email = await context.ReadStateAsync<Email>(message.EmailId, scopeName: EmailStateConstants.EmailStateScope);
await context.AddEventAsync(new WorkflowCompletedEvent($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}"));
await context.YieldOutputAsync($"Email marked as uncertain: {message.Reason}. Email content: {email?.EmailContent}");
}
else
{