.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
@@ -40,14 +40,15 @@ public static class Program
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
// Create the executors
var sloganWriter = new SloganWriterExecutor(chatClient);
var feedbackProvider = new FeedbackExecutor(chatClient);
var sloganWriter = new SloganWriterExecutor("SloganWriter", chatClient);
var feedbackProvider = new FeedbackExecutor("FeedbackProvider", chatClient);
// Build the workflow by adding executors and connecting them
var workflow = new WorkflowBuilder(sloganWriter)
.AddEdge(sloganWriter, feedbackProvider)
.AddEdge(feedbackProvider, sloganWriter)
.Build<string>();
.WithOutputFrom(feedbackProvider)
.Build();
// Execute the workflow
StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Create a slogan for a new electric SUV that is affordable and fun to drive.");
@@ -59,9 +60,9 @@ public static class Program
Console.WriteLine($"{evt}");
}
if (evt is WorkflowCompletedEvent completedEvent)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"{completedEvent}");
Console.WriteLine($"{outputEvent}");
}
}
}
@@ -119,8 +120,9 @@ internal sealed class SloganWriterExecutor
/// <summary>
/// Initializes a new instance of the <see cref="SloganWriterExecutor"/> class.
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="chatClient">The chat client to use for the AI agent.</param>
public SloganWriterExecutor(IChatClient chatClient)
public SloganWriterExecutor(string id, IChatClient chatClient) : base(id)
{
ChatClientAgentOptions agentOptions = new(instructions: "You are a professional slogan writer. You will be given a task to create a slogan.")
{
@@ -189,8 +191,9 @@ internal sealed class FeedbackExecutor : ReflectingExecutor<FeedbackExecutor>, I
/// <summary>
/// Initializes a new instance of the <see cref="FeedbackExecutor"/> class.
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="chatClient">The chat client to use for the AI agent.</param>
public FeedbackExecutor(IChatClient chatClient)
public FeedbackExecutor(string id, IChatClient chatClient) : base(id)
{
ChatClientAgentOptions agentOptions = new(instructions: "You are a professional editor. You will be given a slogan and the task it is meant to accomplish.")
{
@@ -219,13 +222,13 @@ internal sealed class FeedbackExecutor : ReflectingExecutor<FeedbackExecutor>, I
if (feedback.Rating >= this.MinimumRating)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"The following slogan was accepted:\n\n{message.Slogan}"));
await context.YieldOutputAsync($"The following slogan was accepted:\n\n{message.Slogan}");
return;
}
if (this._attempts >= this.MaxAttempts)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}"));
await context.YieldOutputAsync($"The slogan was rejected after {this.MaxAttempts} attempts. Final slogan:\n\n{message.Slogan}");
return;
}
@@ -37,7 +37,7 @@ public static class Program
var workflow = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build<ChatMessage>();
.Build();
// Execute the workflow
StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
@@ -38,7 +38,7 @@ public static class Program
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
// Create the workflow and turn it into an agent
var workflow = WorkflowHelper.GetWorkflow(chatClient);
var workflow = await WorkflowHelper.GetWorkflowAsync(chatClient).ConfigureAwait(false);
var agent = workflow.AsAgent("workflow-agent", "Workflow Agent");
var thread = agent.GetNewThread();
@@ -17,7 +17,7 @@ internal static class WorkflowHelper
/// </summary>
/// <param name="chatClient">The chat client to use for the agents</param>
/// <returns>A workflow that processes input using two language agents</returns>
internal static Workflow<List<ChatMessage>> GetWorkflow(IChatClient chatClient)
internal static ValueTask<Workflow<List<ChatMessage>>> GetWorkflowAsync(IChatClient chatClient)
{
// Create executors
var startExecutor = new ConcurrentStartExecutor();
@@ -29,7 +29,8 @@ internal static class WorkflowHelper
return new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
.Build<List<ChatMessage>>();
.WithOutputFrom(aggregationExecutor)
.BuildAsync<List<ChatMessage>>();
}
/// <summary>
@@ -84,7 +85,7 @@ internal static class WorkflowHelper
if (this._messages.Count == 2)
{
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}"));
await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages));
await context.YieldOutputAsync(formattedMessages);
}
}
}
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.Workflows;
@@ -29,7 +28,7 @@ public static class Program
private static async Task Main()
{
// Create the workflow
var workflow = WorkflowHelper.GetWorkflow();
var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false);
// Create checkpoint manager
var checkpointManager = CheckpointManager.Default;
@@ -58,9 +57,9 @@ public static class Program
}
}
if (evt is WorkflowCompletedEvent workflowCompletedEvt)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
Console.WriteLine($"Workflow completed with result: {outputEvent.Data}");
}
}
@@ -71,15 +70,15 @@ public static class Program
Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}");
// Rehydrate a new workflow instance from a saved checkpoint and continue execution
var newWorkflow = WorkflowHelper.GetWorkflow();
var newWorkflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false);
const int CheckpointIndex = 5;
Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint.");
CheckpointInfo savedCheckpoint = checkpoints[CheckpointIndex];
Checkpointed<StreamingRun> newCheckpointedRun = await InProcessExecution
.StreamAsync(newWorkflow, NumberSignal.Init, checkpointManager)
.ConfigureAwait(false);
await newCheckpointedRun.RestoreCheckpointAsync(savedCheckpoint, CancellationToken.None).ConfigureAwait(false);
Checkpointed<StreamingRun> newCheckpointedRun =
await InProcessExecution.ResumeStreamAsync(newWorkflow, savedCheckpoint, checkpointManager, checkpointedRun.Run.RunId)
.ConfigureAwait(false);
await foreach (WorkflowEvent evt in newCheckpointedRun.Run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is ExecutorCompletedEvent executorCompletedEvt)
@@ -87,9 +86,9 @@ public static class Program
Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed.");
}
if (evt is WorkflowCompletedEvent workflowCompletedEvt)
if (evt is WorkflowOutputEvent workflowOutputEvt)
{
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}");
}
}
}
@@ -16,7 +16,7 @@ internal static class WorkflowHelper
/// 2. JudgeExecutor: Evaluates the guess and provides feedback.
/// The workflow continues until the correct number is guessed.
/// </summary>
internal static Workflow<NumberSignal> GetWorkflow()
internal static ValueTask<Workflow<NumberSignal>> GetWorkflowAsync()
{
// Create the executors
GuessNumberExecutor guessNumberExecutor = new(1, 100);
@@ -26,7 +26,8 @@ internal static class WorkflowHelper
return new WorkflowBuilder(guessNumberExecutor)
.AddEdge(guessNumberExecutor, judgeExecutor)
.AddEdge(judgeExecutor, guessNumberExecutor)
.Build<NumberSignal>();
.WithOutputFrom(judgeExecutor)
.BuildAsync<NumberSignal>();
}
}
@@ -126,8 +127,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor<JudgeExecutor>("Judge
this._tries++;
if (message == this._targetNumber)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!"))
.ConfigureAwait(false);
await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false);
}
else if (message < this._targetNumber)
{
@@ -28,7 +28,7 @@ public static class Program
private static async Task Main()
{
// Create the workflow
var workflow = WorkflowHelper.GetWorkflow();
var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false);
// Create checkpoint manager
var checkpointManager = CheckpointManager.Default;
@@ -57,9 +57,9 @@ public static class Program
}
}
if (evt is WorkflowCompletedEvent workflowCompletedEvt)
if (evt is WorkflowOutputEvent workflowOutputEvt)
{
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}");
}
}
@@ -82,9 +82,9 @@ public static class Program
Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed.");
}
if (evt is WorkflowCompletedEvent workflowCompletedEvt)
if (evt is WorkflowOutputEvent workflowOutputEvt)
{
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}");
}
}
}
@@ -16,7 +16,7 @@ internal static class WorkflowHelper
/// 2. JudgeExecutor: Evaluates the guess and provides feedback.
/// The workflow continues until the correct number is guessed.
/// </summary>
internal static Workflow<NumberSignal> GetWorkflow()
internal static ValueTask<Workflow<NumberSignal>> GetWorkflowAsync()
{
// Create the executors
GuessNumberExecutor guessNumberExecutor = new(1, 100);
@@ -26,7 +26,8 @@ internal static class WorkflowHelper
return new WorkflowBuilder(guessNumberExecutor)
.AddEdge(guessNumberExecutor, judgeExecutor)
.AddEdge(judgeExecutor, guessNumberExecutor)
.Build<NumberSignal>();
.WithOutputFrom(judgeExecutor)
.BuildAsync<NumberSignal>();
}
}
@@ -126,8 +127,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor<JudgeExecutor>("Judge
this._tries++;
if (message == this._targetNumber)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!"))
.ConfigureAwait(false);
await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!").ConfigureAwait(false);
}
else if (message < this._targetNumber)
{
@@ -31,7 +31,7 @@ public static class Program
private static async Task Main()
{
// Create the workflow
var workflow = WorkflowHelper.GetWorkflow();
var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false);
// Create checkpoint manager
var checkpointManager = CheckpointManager.Default;
@@ -63,8 +63,8 @@ public static class Program
Console.WriteLine($"** Checkpoint created at step {checkpoints.Count}.");
}
break;
case WorkflowCompletedEvent workflowCompletedEvt:
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
case WorkflowOutputEvent workflowOutputEvt:
Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}");
break;
}
}
@@ -93,8 +93,8 @@ public static class Program
case ExecutorCompletedEvent executorCompletedEvt:
Console.WriteLine($"* Executor {executorCompletedEvt.ExecutorId} completed.");
break;
case WorkflowCompletedEvent workflowCompletedEvt:
Console.WriteLine($"Workflow completed with result: {workflowCompletedEvt.Data}");
case WorkflowOutputEvent workflowOutputEvt:
Console.WriteLine($"Workflow completed with result: {workflowOutputEvt.Data}");
break;
}
}
@@ -13,7 +13,7 @@ internal static class WorkflowHelper
/// Get a workflow that plays a number guessing game with human-in-the-loop interaction.
/// An input port allows the external world to provide inputs to the workflow upon requests.
/// </summary>
internal static Workflow<SignalWithNumber> GetWorkflow()
internal static ValueTask<Workflow<NumberSignal>> GetWorkflowAsync()
{
// Create the executors
InputPort numberInputPort = InputPort.Create<SignalWithNumber, int>("GuessNumber");
@@ -23,7 +23,8 @@ internal static class WorkflowHelper
return new WorkflowBuilder(numberInputPort)
.AddEdge(numberInputPort, judgeExecutor)
.AddEdge(judgeExecutor, numberInputPort)
.Build<SignalWithNumber>();
.WithOutputFrom(judgeExecutor)
.BuildAsync<NumberSignal>();
}
}
@@ -75,7 +76,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor<JudgeExecutor>("Judge
this._tries++;
if (message == this._targetNumber)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!"))
await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!")
.ConfigureAwait(false);
}
else if (message < this._targetNumber)
@@ -59,15 +59,16 @@ public static class Program
var workflow = new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [physicist, chemist])
.AddFanInEdge(aggregationExecutor, sources: [physicist, chemist])
.Build<string>();
.WithOutputFrom(aggregationExecutor)
.Build();
// Execute the workflow in streaming mode
StreamingRun run = await InProcessExecution.StreamAsync(workflow, "What is temperature?");
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is WorkflowCompletedEvent completed)
if (evt is WorkflowOutputEvent output)
{
Console.WriteLine($"Workflow completed with results:\n{completed.Data}");
Console.WriteLine($"Workflow completed with results:\n{output.Data}");
}
}
}
@@ -118,7 +119,7 @@ internal sealed class ConcurrentAggregationExecutor() :
if (this._messages.Count == 2)
{
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.AuthorName}: {m.Text}"));
await context.AddEventAsync(new WorkflowCompletedEvent(formattedMessages));
await context.YieldOutputAsync(formattedMessages);
}
}
}
@@ -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
{
@@ -53,7 +53,7 @@ internal sealed class Program
Stopwatch timer = Stopwatch.StartNew();
Workflow<string> workflow = this.CreateWorkflow();
Workflow workflow = this.CreateWorkflow();
Notify($"\nWORKFLOW: Defined {timer.Elapsed}");
@@ -100,7 +100,7 @@ internal sealed class Program
Notify("\nWORKFLOW: Done!\n");
}
private Workflow<string> CreateWorkflow()
private Workflow CreateWorkflow()
{
// Use DeclarativeWorkflowBuilder to build a workflow based on a YAML file.
DeclarativeWorkflowOptions options =
@@ -26,7 +26,7 @@ public static class Program
private static async Task Main()
{
// Create the workflow
var workflow = WorkflowHelper.GetWorkflow();
var workflow = await WorkflowHelper.GetWorkflowAsync().ConfigureAwait(false);
// Execute the workflow
StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
@@ -40,9 +40,9 @@ public static class Program
await handle.SendResponseAsync(response).ConfigureAwait(false);
break;
case WorkflowCompletedEvent workflowCompleteEvt:
// The workflow has completed successfully
Console.WriteLine($"Workflow completed with result: {workflowCompleteEvt.Data}");
case WorkflowOutputEvent outputEvt:
// The workflow has yielded output
Console.WriteLine($"Workflow completed with result: {outputEvt.Data}");
return;
}
}
@@ -12,7 +12,7 @@ internal static class WorkflowHelper
/// Get a workflow that plays a number guessing game with human-in-the-loop interaction.
/// An input port allows the external world to provide inputs to the workflow upon requests.
/// </summary>
internal static Workflow<NumberSignal> GetWorkflow()
internal static ValueTask<Workflow<NumberSignal>> GetWorkflowAsync()
{
// Create the executors
InputPort numberInputPort = InputPort.Create<NumberSignal, int>("GuessNumber");
@@ -22,7 +22,8 @@ internal static class WorkflowHelper
return new WorkflowBuilder(numberInputPort)
.AddEdge(numberInputPort, judgeExecutor)
.AddEdge(judgeExecutor, numberInputPort)
.Build<NumberSignal>();
.WithOutputFrom(judgeExecutor)
.BuildAsync<NumberSignal>();
}
}
@@ -58,7 +59,7 @@ internal sealed class JudgeExecutor() : ReflectingExecutor<JudgeExecutor>("Judge
this._tries++;
if (message == this._targetNumber)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!"))
await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!")
.ConfigureAwait(false);
}
else if (message < this._targetNumber)
@@ -24,22 +24,23 @@ public static class Program
private static async Task Main()
{
// Create the executors
GuessNumberExecutor guessNumberExecutor = new(1, 100);
JudgeExecutor judgeExecutor = new(42);
GuessNumberExecutor guessNumberExecutor = new("GuessNumber", 1, 100);
JudgeExecutor judgeExecutor = new("Judge", 42);
// Build the workflow by connecting executors in a loop
var workflow = new WorkflowBuilder(guessNumberExecutor)
var workflow = await new WorkflowBuilder(guessNumberExecutor)
.AddEdge(guessNumberExecutor, judgeExecutor)
.AddEdge(judgeExecutor, guessNumberExecutor)
.Build<NumberSignal>();
.WithOutputFrom(judgeExecutor)
.BuildAsync<NumberSignal>();
// Execute the workflow
StreamingRun run = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is WorkflowCompletedEvent workflowCompleteEvt)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine($"Result: {workflowCompleteEvt}");
Console.WriteLine($"Result: {outputEvent}");
}
}
}
@@ -73,9 +74,10 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
/// <summary>
/// Initializes a new instance of the <see cref="GuessNumberExecutor"/> class.
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="lowerBound">The initial lower bound of the guessing range.</param>
/// <param name="upperBound">The initial upper bound of the guessing range.</param>
public GuessNumberExecutor(int lowerBound, int upperBound)
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id)
{
this.LowerBound = lowerBound;
this.UpperBound = upperBound;
@@ -113,8 +115,9 @@ internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessag
/// <summary>
/// Initializes a new instance of the <see cref="JudgeExecutor"/> class.
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="targetNumber">The number to be guessed.</param>
public JudgeExecutor(int targetNumber)
public JudgeExecutor(string id, int targetNumber) : base(id)
{
this._targetNumber = targetNumber;
}
@@ -124,7 +127,7 @@ internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessag
this._tries++;
if (message == this._targetNumber)
{
await context.AddEventAsync(new WorkflowCompletedEvent($"{this._targetNumber} found in {this._tries} tries!"))
await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!")
.ConfigureAwait(false);
}
else if (message < this._targetNumber)
@@ -33,15 +33,16 @@ public static class Program
var workflow = new WorkflowBuilder(fileRead)
.AddFanOutEdge(fileRead, targets: [wordCount, paragraphCount])
.AddFanInEdge(aggregate, sources: [wordCount, paragraphCount])
.Build<string>();
.WithOutputFrom(aggregate)
.Build();
// Execute the workflow with input data
Run run = await InProcessExecution.RunAsync(workflow, "Lorem_Ipsum.txt");
foreach (WorkflowEvent evt in run.NewEvents)
{
if (evt is WorkflowCompletedEvent workflowCompleted)
if (evt is WorkflowOutputEvent outputEvent)
{
Console.WriteLine(workflowCompleted.Data);
Console.WriteLine(outputEvent.Data);
}
}
}
@@ -116,7 +117,7 @@ internal sealed class AggregationExecutor() : ReflectingExecutor<AggregationExec
// Aggregate the results from both executors
var totalParagraphCount = this._messages.Sum(m => m.ParagraphCount);
var totalWordCount = this._messages.Sum(m => m.WordCount);
await context.AddEventAsync(new WorkflowCompletedEvent($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}"));
await context.YieldOutputAsync($"Total Paragraphs: {totalParagraphCount}, Total Words: {totalWordCount}");
}
}
}
@@ -29,8 +29,8 @@ public static class Program
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse);
var workflow = builder.Build<string>();
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
var workflow = builder.Build();
// Execute the workflow with input data
Run run = await InProcessExecution.RunAsync(workflow, "Hello, World!");
@@ -72,11 +72,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExec
/// <returns>The input text reversed</returns>
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context)
{
string result = string.Concat(message.Reverse());
// Signal that the workflow is complete
await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false);
return result;
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
return string.Concat(message.Reverse());
}
}
@@ -28,8 +28,8 @@ public static class Program
// Build the workflow by connecting executors sequentially
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse);
var workflow = builder.Build<string>();
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
var workflow = builder.Build();
// Execute the workflow in streaming mode
StreamingRun run = await InProcessExecution.StreamAsync(workflow, "Hello, World!");
@@ -71,11 +71,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExec
/// <returns>The input text reversed</returns>
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context)
{
string result = string.Concat(message.Reverse());
// Signal that the workflow is complete
await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false);
return result;
// Because we do not suppress it, the returned result will be yielded as an output from this executor.
return string.Concat(message.Reverse());
}
}
@@ -43,7 +43,7 @@ public static class Program
var workflow = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build<ChatMessage>();
.Build();
// Execute the workflow
StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
@@ -84,7 +84,7 @@ public static class Program
throw new InvalidOperationException("Invalid workflow type.");
}
static async Task<List<ChatMessage>> RunWorkflowAsync(Workflow<List<ChatMessage>> workflow, List<ChatMessage> messages)
static async Task<List<ChatMessage>> RunWorkflowAsync(Workflow workflow, List<ChatMessage> messages)
{
string? lastExecutorId = null;
@@ -108,10 +108,10 @@ public static class Program
Console.WriteLine($" [Calling function '{call.Name}' with arguments: {JsonSerializer.Serialize(call.Arguments)}]");
}
}
else if (evt is WorkflowCompletedEvent completed)
else if (evt is WorkflowOutputEvent output)
{
Console.WriteLine();
return (List<ChatMessage>)completed.Data!;
return output.As<List<ChatMessage>>()!;
}
}
@@ -58,7 +58,9 @@ AIAgent reporter = new ChatClientAgent(anthropic,
description: "Summarize the researcher's essay into a single paragraph, focusing only on the fact checker's confirmed facts.");
// Build a sequential workflow: Researcher -> Fact-Checker -> Reporter
AIAgent workflowAgent = AgentWorkflowBuilder.BuildSequential(researcher, factChecker, reporter).AsAgent();
AIAgent workflowAgent = await AgentWorkflowBuilder.BuildSequential(researcher, factChecker, reporter)
.AsAgentAsync()
.ConfigureAwait(false);
// Run the workflow, streaming the output as it arrives.
string? lastAuthor = null;