.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
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step1EntryPoint
{
public static Workflow<string> WorkflowInstance
public static Workflow WorkflowInstance
{
get
{
@@ -19,7 +19,7 @@ internal static class Step1EntryPoint
WorkflowBuilder builder = new(uppercase);
builder.AddEdge(uppercase, reverse);
return builder.Build<string>();
return builder.Build();
}
}
@@ -49,7 +49,7 @@ internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExec
{
string result = string.Concat(message.Reverse());
await context.AddEventAsync(new WorkflowCompletedEvent(result)).ConfigureAwait(false);
await context.AddEventAsync(new RequestHaltEvent(result)).ConfigureAwait(false);
return result;
}
}
@@ -13,7 +13,7 @@ internal static class Step1aEntryPoint
{
Run run = await InProcessExecution.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
Assert.Equal(RunStatus.Completed, run.Status);
Assert.Equal(RunStatus.Idle, run.Status);
foreach (WorkflowEvent evt in run.NewEvents)
{
@@ -10,20 +10,21 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step2EntryPoint
{
public static Workflow<string> WorkflowInstance
public static Workflow WorkflowInstance
{
get
{
string[] spamKeywords = ["spam", "advertisement", "offer"];
DetectSpamExecutor detectSpam = new(spamKeywords);
RespondToMessageExecutor respondToMessage = new();
RemoveSpamExecutor removeSpam = new();
DetectSpamExecutor detectSpam = new("DetectSpam", spamKeywords);
RespondToMessageExecutor respondToMessage = new("RespondToMessage");
RemoveSpamExecutor removeSpam = new("RemoveSpam");
return new WorkflowBuilder(detectSpam)
.AddEdge(detectSpam, respondToMessage, (bool isSpam) => !isSpam) // If not spam, respond
.AddEdge(detectSpam, removeSpam, (bool isSpam) => isSpam) // If spam, remove
.Build<string>();
.WithOutputFrom(respondToMessage, removeSpam)
.Build();
}
}
@@ -34,9 +35,9 @@ internal static class Step2EntryPoint
{
switch (evt)
{
case WorkflowCompletedEvent workflowCompleteEvt:
case WorkflowOutputEvent workflowOutputEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowCompleteEvt.Data!.ToString()!;
string workflowResult = workflowOutputEvt.As<string>()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompletedEvt:
@@ -45,7 +46,7 @@ internal static class Step2EntryPoint
}
}
throw new InvalidOperationException("Workflow failed to yield the completion event.");
throw new InvalidOperationException("Workflow failed to yield an output.");
}
}
@@ -53,7 +54,7 @@ internal sealed class DetectSpamExecutor : ReflectingExecutor<DetectSpamExecutor
{
public string[] SpamKeywords { get; }
public DetectSpamExecutor(params string[] spamKeywords)
public DetectSpamExecutor(string id, params string[] spamKeywords) : base(id)
{
this.SpamKeywords = spamKeywords;
}
@@ -70,7 +71,7 @@ internal sealed class DetectSpamExecutor : ReflectingExecutor<DetectSpamExecutor
}
}
internal sealed class RespondToMessageExecutor : ReflectingExecutor<RespondToMessageExecutor>, IMessageHandler<bool>
internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<RespondToMessageExecutor>(id), IMessageHandler<bool>
{
public const string ActionResult = "Message processed successfully.";
@@ -84,12 +85,12 @@ internal sealed class RespondToMessageExecutor : ReflectingExecutor<RespondToMes
await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay
await context.AddEventAsync(new WorkflowCompletedEvent(ActionResult))
await context.YieldOutputAsync(ActionResult)
.ConfigureAwait(false);
}
}
internal sealed class RemoveSpamExecutor : ReflectingExecutor<RemoveSpamExecutor>, IMessageHandler<bool>
internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor<RemoveSpamExecutor>(id), IMessageHandler<bool>
{
public const string ActionResult = "Spam message removed.";
@@ -103,7 +104,7 @@ internal sealed class RemoveSpamExecutor : ReflectingExecutor<RemoveSpamExecutor
await Task.Delay(1000).ConfigureAwait(false); // Simulate some processing delay
await context.AddEventAsync(new WorkflowCompletedEvent(ActionResult))
await context.YieldOutputAsync(ActionResult)
.ConfigureAwait(false);
}
}
@@ -10,17 +10,18 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step3EntryPoint
{
public static Workflow<NumberSignal> WorkflowInstance
public static Workflow WorkflowInstance
{
get
{
GuessNumberExecutor guessNumber = new(1, 100);
JudgeExecutor judge = new(42); // Let's say the target number is 42
GuessNumberExecutor guessNumber = new("GuessNumber", 1, 100);
JudgeExecutor judge = new("Judge", 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
.AddEdge(guessNumber, judge)
.AddEdge(judge, guessNumber)
.Build<NumberSignal>();
.WithOutputFrom(guessNumber)
.Build();
}
}
@@ -32,9 +33,9 @@ internal static class Step3EntryPoint
{
switch (evt)
{
case WorkflowCompletedEvent workflowCompleteEvt:
case WorkflowOutputEvent workflowOutputEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowCompleteEvt.Data!.ToString()!;
string workflowResult = workflowOutputEvt.As<string>()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompletedEvt:
@@ -43,7 +44,7 @@ internal static class Step3EntryPoint
}
}
throw new InvalidOperationException("Workflow failed to yield the completion event.");
throw new InvalidOperationException("Workflow failed to yield an output.");
}
}
@@ -60,7 +61,7 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
public int LowerBound { get; private set; }
public int UpperBound { get; private set; }
public GuessNumberExecutor(int lowerBound, int upperBound)
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false })
{
this.LowerBound = lowerBound;
this.UpperBound = upperBound;
@@ -74,7 +75,7 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
switch (message)
{
case NumberSignal.Matched:
await context.AddEventAsync(new WorkflowCompletedEvent($"Guessed the number: {this._currGuess}"))
await context.YieldOutputAsync($"Guessed the number: {this._currGuess}")
.ConfigureAwait(false);
break;
@@ -97,7 +98,7 @@ internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessag
internal int? Tries { get; private set; }
public JudgeExecutor(int targetNumber)
public JudgeExecutor(string id, int targetNumber) : base(id)
{
this._targetNumber = targetNumber;
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
@@ -8,18 +9,27 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step4EntryPoint
{
public static Workflow<NumberSignal, string> CreateWorkflowInstance(out JudgeExecutor judge)
internal const string JudgeId = "Judge";
public static Workflow CreateWorkflowInstance(out JudgeExecutor judge)
{
InputPort guessNumber = InputPort.Create<NumberSignal, int>("GuessNumber");
judge = new(42); // Let's say the target number is 42
judge = new(JudgeId, 42); // Let's say the target number is 42
return new WorkflowBuilder(guessNumber)
.AddEdge(guessNumber, judge)
.AddEdge(judge, guessNumber, (NumberSignal signal) => signal != NumberSignal.Matched)
.BuildWithOutput<NumberSignal, NumberSignal, string>(judge, ComputeStreamingOutput, (s, _) => s is NumberSignal.Matched);
.WithOutputFrom(judge)
.Build();
}
public static Workflow<NumberSignal, string> WorkflowInstance
public static ValueTask<Workflow<NumberSignal>?> GetPromotedWorklowInstanceAsync()
{
Workflow workflow = CreateWorkflowInstance(out _);
return workflow.TryPromoteAsync<NumberSignal>();
}
public static Workflow WorkflowInstance
{
get
{
@@ -29,30 +39,53 @@ internal static class Step4EntryPoint
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback)
{
Workflow<NumberSignal, string> workflow = WorkflowInstance;
StreamingRun<string> handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
NumberSignal signal = NumberSignal.Init;
string? prompt = UpdatePrompt(null, signal);
Workflow workflow = WorkflowInstance;
StreamingRun handle = await InProcessExecution.StreamAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
List<ExternalRequest> requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
{
switch (evt)
{
case WorkflowOutputEvent outputEvent:
switch (outputEvent.SourceId)
{
case JudgeId:
if (!outputEvent.Is<NumberSignal>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
signal = outputEvent.As<NumberSignal?>()!.Value;
prompt = UpdatePrompt(prompt, signal);
break;
}
break;
case RequestInfoEvent requestInputEvt:
ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput);
await handle.SendResponseAsync(response).ConfigureAwait(false);
requests.Add(requestInputEvt.Request);
break;
case SuperStepCompletedEvent stepCompletedEvent:
foreach (ExternalRequest request in requests)
{
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
await handle.SendResponseAsync(response).ConfigureAwait(false);
}
requests.Clear();
break;
case WorkflowCompletedEvent workflowCompleteEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowCompleteEvt.Data!.ToString()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompletedEvt:
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
break;
}
}
throw new InvalidOperationException("Workflow failed to yield the completion event.");
writer.WriteLine($"Result: {prompt}");
return prompt!;
}
private static ExternalResponse ExecuteExternalRequest(
@@ -73,16 +106,10 @@ internal static class Step4EntryPoint
/// This converts the incoming <see cref="NumberSignal"/> from the judge to a status text that can be displayed
/// to the user.
/// </summary>
/// <remarks>
/// This works correctly timing-wise because both the <see cref="StreamingAggregator{TInput, TOutput}"/> and the
/// <see cref="InputPort"/> are one edge from the <see cref="JudgeExecutor"/> (see the workflow definition in the
/// <see cref="RunAsync"/> method). That means they will get the <see cref="NumberSignal"/> at the same time (one
/// SuperStep after the Judge has generated it.)
/// </remarks>
/// <param name="signal"></param>
/// <param name="runningResult"></param>
/// <param name="signal"></param>
/// <returns></returns>
private static string ComputeStreamingOutput(NumberSignal signal, string? runningResult)
internal static string? UpdatePrompt(string? runningResult, NumberSignal signal)
{
return signal switch
{
@@ -90,7 +117,7 @@ internal static class Step4EntryPoint
NumberSignal.Above => "Your guess was too high. Try again.",
NumberSignal.Below => "Your guess was too low. Try again.",
_ => runningResult ?? string.Empty
_ => runningResult
};
}
}
@@ -13,18 +13,23 @@ internal static class Step5EntryPoint
{
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null)
{
Dictionary<CheckpointInfo, (NumberSignal signal, string? prompt)> checkpointedOutputs = new();
NumberSignal signal = NumberSignal.Init;
string? prompt = Step4EntryPoint.UpdatePrompt(null, signal);
checkpointManager ??= CheckpointManager.Default;
Workflow<NumberSignal, string> workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge);
Checkpointed<StreamingRun<string>> checkpointed =
Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge);
Checkpointed<StreamingRun> checkpointed =
await InProcessExecution.StreamAsync(workflow, NumberSignal.Init, checkpointManager)
.ConfigureAwait(false);
List<CheckpointInfo> checkpoints = [];
CancellationTokenSource cancellationSource = new();
StreamingRun<string> handle = checkpointed.Run;
string? result = await RunStreamToHaltOrMaxStepAsync(6).ConfigureAwait(false);
StreamingRun handle = checkpointed.Run;
string? result = await RunStreamToHaltOrMaxStepAsync(maxStep: 6).ConfigureAwait(false);
result.Should().BeNull();
checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step");
@@ -34,7 +39,7 @@ internal static class Step5EntryPoint
if (rehydrateToRestore)
{
checkpointed = await InProcessExecution.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, CancellationToken.None)
checkpointed = await InProcessExecution.ResumeStreamAsync(workflow, targetCheckpoint, checkpointManager, runId: handle.RunId, cancellation: CancellationToken.None)
.ConfigureAwait(false);
handle = checkpointed.Run;
}
@@ -43,6 +48,8 @@ internal static class Step5EntryPoint
await checkpointed.RestoreCheckpointAsync(checkpoints[2], CancellationToken.None).ConfigureAwait(false);
}
(signal, prompt) = checkpointedOutputs[targetCheckpoint];
judge.Tries.Should().Be(1);
cancellationSource.Dispose();
@@ -52,7 +59,7 @@ internal static class Step5EntryPoint
result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false);
result.Should().NotBeNull();
checkpoints.Should().HaveCount(6);
checkpoints.Should().HaveCount(7);
cancellationSource.Dispose();
@@ -60,31 +67,56 @@ internal static class Step5EntryPoint
async ValueTask<string?> RunStreamToHaltOrMaxStepAsync(int? maxStep = null)
{
List<ExternalRequest> requests = [];
await foreach (WorkflowEvent evt in handle.WatchStreamAsync(cancellationSource.Token).ConfigureAwait(false))
{
switch (evt)
{
case WorkflowOutputEvent outputEvent:
switch (outputEvent.SourceId)
{
case Step4EntryPoint.JudgeId:
if (!outputEvent.Is<NumberSignal>())
{
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
}
signal = outputEvent.As<NumberSignal?>()!.Value;
prompt = Step4EntryPoint.UpdatePrompt(null, signal);
break;
}
break;
case RequestInfoEvent requestInputEvt:
requests.Add(requestInputEvt.Request);
break;
case SuperStepCompletedEvent stepCompletedEvt:
CheckpointInfo? checkpoint = stepCompletedEvt.CompletionInfo!.Checkpoint;
if (checkpoint is not null)
{
checkpoints.Add(checkpoint);
checkpointedOutputs[checkpoint] = (signal, prompt);
}
if (maxStep.HasValue && stepCompletedEvt.StepNumber >= maxStep.Value - 1)
{
cancellationSource.Cancel();
}
else
{
foreach (ExternalRequest request in requests)
{
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
await handle.SendResponseAsync(response).ConfigureAwait(false);
}
requests.Clear();
}
break;
case RequestInfoEvent requestInputEvt:
ExternalResponse response = ExecuteExternalRequest(requestInputEvt.Request, userGuessCallback, workflow.RunningOutput);
await handle.SendResponseAsync(response).ConfigureAwait(false);
break;
case WorkflowCompletedEvent workflowCompleteEvt:
// The workflow has completed successfully, return the result
string workflowResult = workflowCompleteEvt.Data!.ToString()!;
writer.WriteLine($"Result: {workflowResult}");
return workflowResult;
case ExecutorCompletedEvent executorCompleteEvt:
writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}");
break;
@@ -96,7 +128,8 @@ internal static class Step5EntryPoint
return null;
}
throw new InvalidOperationException("Workflow failed to yield the completion event.");
writer.WriteLine($"Result: {prompt}");
return prompt!;
}
}
@@ -17,7 +17,7 @@ namespace Microsoft.Agents.Workflows.Sample;
internal static class Step6EntryPoint
{
public static Workflow<List<ChatMessage>> CreateWorkflow(int maxTurns) =>
public static Workflow CreateWorkflow(int maxTurns) =>
AgentWorkflowBuilder
.CreateGroupChatBuilderWith(agents => new AgentWorkflowBuilder.RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns })
.AddParticipants(new HelloAgent(), new EchoAgent())
@@ -25,9 +25,9 @@ internal static class Step6EntryPoint
public static async ValueTask RunAsync(TextWriter writer, int maxSteps = 2)
{
Workflow<List<ChatMessage>> workflow = CreateWorkflow(maxSteps);
Workflow workflow = CreateWorkflow(maxSteps);
StreamingRun run = await InProcessExecution.StreamAsync(workflow, [])
StreamingRun run = await InProcessExecution.StreamAsync(workflow, Array.Empty<ChatMessage>())
.ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
@@ -13,7 +13,10 @@ internal static class Step7EntryPoint
{
public static async ValueTask RunAsync(TextWriter writer, int maxSteps = 2)
{
Workflow<List<ChatMessage>> workflow = Step6EntryPoint.CreateWorkflow(maxSteps);
Workflow<List<ChatMessage>> workflow = (await Step6EntryPoint.CreateWorkflow(maxSteps)
.TryPromoteAsync<List<ChatMessage>>()
.ConfigureAwait(false))!;
AIAgent agent = workflow.AsAgent("group-chat-agent", "Group Chat Agent");
AgentThread thread = agent.GetNewThread();