.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
@@ -71,7 +71,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
Configuration = workflowConfig,
LoggerFactory = this.Output
};
Workflow<TInput> workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(workflowPath, workflowOptions);
WorkflowEvents workflowEvents = await WorkflowHarness.RunAsync(workflow, (TInput)GetInput<TInput>(testcase));
foreach (DeclarativeActionInvokedEvent actionInvokeEvent in workflowEvents.ActionInvokeEvents)
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.Workflows.Declarative.IntegrationTests.Framework;
internal static class WorkflowHarness
{
public static async Task<WorkflowEvents> RunAsync<TInput>(Workflow<TInput> workflow, TInput input) where TInput : notnull
public static async Task<WorkflowEvents> RunAsync<TInput>(Workflow workflow, TInput input) where TInput : notnull
{
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
IReadOnlyList<WorkflowEvent> workflowEvents = run.WatchStreamAsync().ToEnumerable().ToList();
@@ -252,7 +252,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
Mock<WorkflowAgentProvider> mockAgentProvider = new(MockBehavior.Strict);
DeclarativeWorkflowOptions workflowContext = new(mockAgentProvider.Object) { LoggerFactory = this.Output };
Workflow<TInput> workflow = DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
Workflow workflow = DeclarativeWorkflowBuilder.Build<TInput>(yamlReader, workflowContext);
StreamingRun run = await InProcessExecution.StreamAsync(workflow, workflowInput);
@@ -29,7 +29,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor
TestWorkflowExecutor workflowExecutor = new();
WorkflowBuilder workflowBuilder = new(workflowExecutor);
workflowBuilder.AddEdge(workflowExecutor, executor);
StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build<WorkflowFormulaState>(), this.State);
StreamingRun run = await InProcessExecution.StreamAsync(workflowBuilder.Build(), this.State);
WorkflowEvent[] events = await run.WatchStreamAsync().ToArrayAsync();
Assert.Contains(events, e => e is DeclarativeActionInvokedEvent);
Assert.Contains(events, e => e is DeclarativeActionCompletedEvent);
@@ -382,28 +382,28 @@ public class AgentWorkflowBuilderTests
}
private static async Task<(string UpdateText, List<ChatMessage>? Result)> RunWorkflowAsync(
Workflow<List<ChatMessage>> workflow, List<ChatMessage> input)
Workflow workflow, List<ChatMessage> input)
{
StringBuilder sb = new();
StreamingRun run = await InProcessExecution.StreamAsync(workflow, input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
WorkflowCompletedEvent? completed = null;
WorkflowOutputEvent? output = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
{
if (evt is AgentRunUpdateEvent executorComplete)
{
sb.Append(executorComplete.Data);
}
else if (evt is WorkflowCompletedEvent e)
else if (evt is WorkflowOutputEvent e)
{
completed = e;
output = e;
break;
}
}
return (sb.ToString(), completed?.Data as List<ChatMessage>);
return (sb.ToString(), output?.As<List<ChatMessage>>());
}
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
@@ -2,7 +2,7 @@
namespace Microsoft.Agents.Workflows.UnitTests;
internal sealed class ForwardMessageExecutor<TMessage>(string? id = null) : Executor(id) where TMessage : notnull
internal sealed class ForwardMessageExecutor<TMessage>(string id) : Executor(id) where TMessage : notnull
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TMessage>((message, ctx) => ctx.SendMessageAsync(message));
@@ -95,12 +95,12 @@ public class InProcessStateTests
ValidateState(1)
);
Workflow<TurnToken> workflow =
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build<TurnToken>();
.AddEdge(validator, writer, MaxTurns(4)).Build();
Run run = await InProcessExecution.RunAsync(workflow, new());
Run run = await InProcessExecution.RunAsync<TurnToken>(workflow, new());
run.Status.Should().Be(RunStatus.Idle);
}
@@ -122,12 +122,12 @@ public class InProcessStateTests
ValidateState(1)
);
Workflow<TurnToken> workflow =
Workflow workflow =
new WorkflowBuilder(writer)
.AddEdge(writer, validator, MaxTurns(4))
.AddEdge(validator, writer, MaxTurns(4)).Build<TurnToken>();
.AddEdge(validator, writer, MaxTurns(4)).Build();
Checkpointed<Run> checkpointed = await InProcessExecution.RunAsync(workflow, new(), CheckpointManager.Default);
Checkpointed<Run> checkpointed = await InProcessExecution.RunAsync<TurnToken>(workflow, new(), CheckpointManager.Default);
checkpointed.Checkpoints.Should().HaveCount(6);
checkpointed.Run.Status.Should().Be(RunStatus.Idle);
@@ -136,7 +136,7 @@ public class InProcessStateTests
[Fact]
public async Task InProcessRun_StateShouldError_TwoExecutorsAsync()
{
ForwardMessageExecutor<TurnToken> forward = new();
ForwardMessageExecutor<TurnToken> forward = new(nameof(ForwardMessageExecutor<TurnToken>));
using StateTestExecutor<int?> testExecutor = new(
new ScopeKey("StateTestExecutor", "TestScope", "TestKey"),
loop: false,
@@ -149,12 +149,12 @@ public class InProcessStateTests
CreateOrIncrement()
);
Workflow<TurnToken> workflow =
Workflow workflow =
new WorkflowBuilder(forward)
.AddFanOutEdge(forward, targets: [testExecutor, testExecutor2])
.Build<TurnToken>();
.Build();
var act = async () => await InProcessExecution.RunAsync(workflow, new());
var act = async () => await InProcessExecution.RunAsync(workflow, new TurnToken());
var result = await act.Should()
.ThrowAsync("multiple writers to the same shared scope key");
@@ -8,6 +8,7 @@ using System.Linq.Expressions;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.Workflows.Checkpointing;
using Microsoft.Agents.Workflows.Execution;
@@ -154,7 +155,7 @@ public class JsonSerializationTests
private static InputPortInfo IntToString => InputPort.Create<int, string>(IntToStringId).ToPortInfo();
private static InputPortInfo StringToInt => InputPort.Create<string, int>(StringToIntId).ToPortInfo();
private static Workflow<string, int> CreateTestWorkflow()
private static ValueTask<Workflow<string>> CreateTestWorkflowAsync()
{
ForwardMessageExecutor<string> forwardString = new(ForwardStringId);
ForwardMessageExecutor<int> forwardInt = new(ForwardIntId);
@@ -165,14 +166,17 @@ public class JsonSerializationTests
WorkflowBuilder builder = new(forwardString);
builder.AddEdge(forwardString, stringToInt)
.AddEdge(stringToInt, forwardInt)
.AddEdge(forwardInt, intToString);
.AddEdge(forwardInt, intToString)
.AddEdge(intToString, StreamingAggregators.Last<int>().AsExecutor("Aggregate"));
return builder.BuildWithOutput<string, int, int>(
intToString,
StreamingAggregators.Last<int>(), (_, __) => true);
return builder.BuildAsync<string>();
}
private static WorkflowInfo TestWorkflowInfo => CreateTestWorkflow().ToWorkflowInfo();
private static async ValueTask<WorkflowInfo> CreateTestWorkflowInfoAsync()
{
Workflow<string> testWorkflow = await CreateTestWorkflowAsync().ConfigureAwait(false);
return testWorkflow.ToWorkflowInfo();
}
private static void ValidateWorkflowInfo(WorkflowInfo actual, WorkflowInfo prototype)
{
@@ -182,8 +186,8 @@ public class JsonSerializationTests
actual.InputType.Should().Match(prototype.InputType.CreateValidator());
actual.StartExecutorId.Should().Be(prototype.StartExecutorId);
actual.OutputType.Should().NotBeNull().And.Match(prototype.OutputType!.CreateValidator());
actual.OutputCollectorId.Should().NotBeNull().And.Be(prototype.OutputCollectorId);
actual.OutputExecutorIds.Should().HaveCount(prototype.OutputExecutorIds.Count)
.And.AllSatisfy(id => prototype.OutputExecutorIds.Contains(id));
void ValidateExecutorDictionary(Dictionary<string, ExecutorInfo> expected,
Dictionary<string, List<EdgeInfo>> expectedEdges,
@@ -226,9 +230,9 @@ public class JsonSerializationTests
}
[Fact]
public void Test_WorkflowInfo_JsonRoundtrip()
public async Task Test_WorkflowInfo_JsonRoundtripAsync()
{
WorkflowInfo prototype = TestWorkflowInfo;
WorkflowInfo prototype = await CreateTestWorkflowInfoAsync();
JsonMarshaller marshaller = new();
@@ -634,9 +638,10 @@ public class JsonSerializationTests
private static CheckpointInfo TestParentCheckpointInfo => new(s_runId, s_parentCheckpointId);
[Fact]
public void Test_Checkpoint_JsonRoundTrip()
public async Task Test_Checkpoint_JsonRoundTripAsync()
{
Checkpoint prototype = new(12, TestWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
WorkflowInfo testWorkflowInfo = await CreateTestWorkflowInfoAsync();
Checkpoint prototype = new(12, testWorkflowInfo, TestRunnerStateData, TestStateData, TestEdgeState, TestParentCheckpointInfo);
Checkpoint result = RunJsonRoundtrip(prototype, TestCustomSerializedJsonOptions);
result.Should().Match((Checkpoint checkpoint) => checkpoint.StepNumber == prototype.StepNumber);
@@ -8,7 +8,7 @@ using Moq;
namespace Microsoft.Agents.Workflows.UnitTests;
public class BaseTestExecutor<TActual> : ReflectingExecutor<TActual> where TActual : ReflectingExecutor<TActual>
public class BaseTestExecutor<TActual>(string id) : ReflectingExecutor<TActual>(id) where TActual : ReflectingExecutor<TActual>
{
protected void OnInvokedHandler() => this.InvokedHandler = true;
@@ -19,7 +19,7 @@ public class BaseTestExecutor<TActual> : ReflectingExecutor<TActual> where TActu
}
}
public class DefaultHandler : BaseTestExecutor<DefaultHandler>, IMessageHandler<object>
public class DefaultHandler() : BaseTestExecutor<DefaultHandler>(nameof(DefaultHandler)), IMessageHandler<object>
{
public ValueTask HandleAsync(object message, IWorkflowContext context)
{
@@ -34,7 +34,7 @@ public class DefaultHandler : BaseTestExecutor<DefaultHandler>, IMessageHandler<
} = (message, context) => default;
}
public class TypedHandler<TInput> : BaseTestExecutor<TypedHandler<TInput>>, IMessageHandler<TInput>
public class TypedHandler<TInput>() : BaseTestExecutor<TypedHandler<TInput>>(nameof(TypedHandler<TInput>)), IMessageHandler<TInput>
{
public ValueTask HandleAsync(TInput message, IWorkflowContext context)
{
@@ -49,7 +49,7 @@ public class TypedHandler<TInput> : BaseTestExecutor<TypedHandler<TInput>>, IMes
} = (message, context) => default;
}
public class TypedHandlerWithOutput<TInput, TResult> : BaseTestExecutor<TypedHandlerWithOutput<TInput, TResult>>, IMessageHandler<TInput, TResult>
public class TypedHandlerWithOutput<TInput, TResult>() : BaseTestExecutor<TypedHandlerWithOutput<TInput, TResult>>(nameof(TypedHandlerWithOutput<TInput, TResult>)), IMessageHandler<TInput, TResult>
{
public ValueTask<TResult> HandleAsync(TInput message, IWorkflowContext context)
{
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.Workflows.UnitTests;
public class RepresentationTests
{
private sealed class TestExecutor : Executor
private sealed class TestExecutor() : Executor("TestExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder;
}
@@ -79,9 +79,6 @@ public class RepresentationTests
{
await RunExecutorishInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorishInfoMatchTestAsync(new RequestInfoExecutor(TestInputPort));
OutputCollectorExecutor<ChatMessage, IEnumerable<ChatMessage>> outputCollector = new(StreamingAggregators.Union<ChatMessage>());
await RunExecutorishInfoMatchTestAsync(outputCollector);
}
private static string Source(int id) => $"Source/{id}";
@@ -158,17 +155,23 @@ public class RepresentationTests
}
[Fact]
public void Test_Sample_WorkflowInfos()
public async Task Test_Sample_WorkflowInfosAsync()
{
RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step2EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step3EntryPoint.WorkflowInstance);
RunWorkflowInfoMatchTest(Step4EntryPoint.WorkflowInstance);
Workflow<string> workflowStep1 = (await Step1EntryPoint.WorkflowInstance.TryPromoteAsync<string>())!;
RunWorkflowInfoMatchTest(workflowStep1);
Workflow<string> workflowStep2 = (await Step2EntryPoint.WorkflowInstance.TryPromoteAsync<string>())!;
RunWorkflowInfoMatchTest(workflowStep2);
RunWorkflowInfoMatchTest((await Step3EntryPoint.WorkflowInstance.TryPromoteAsync<NumberSignal>())!);
RunWorkflowInfoMatchTest((await Step4EntryPoint.WorkflowInstance.TryPromoteAsync<NumberSignal>())!);
// Step 5 reuses the workflow from Step 4, so we don't need to test it separately.
RunWorkflowInfoMatchTest(Step6EntryPoint.CreateWorkflow(2));
RunWorkflowInfoMatchTest((await Step6EntryPoint.CreateWorkflow(2).TryPromoteAsync<List<ChatMessage>>())!);
// Step 7 reuses the workflow from Step 6, so we don't need to test it separately.
RunWorkflowInfoMatchTest(Step1EntryPoint.WorkflowInstance, Step2EntryPoint.WorkflowInstance, expect: false);
RunWorkflowInfoMatchTest(workflowStep1, workflowStep2, expect: false);
static void RunWorkflowInfoMatchTest<TInput>(Workflow<TInput> workflow, Workflow<TInput>? comparator = null, bool expect = true)
{
@@ -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();
@@ -119,6 +119,12 @@ public class SpecializedExecutorSmokeTests
public ValueTask AddEventAsync(WorkflowEvent workflowEvent) =>
default;
public ValueTask YieldOutputAsync(object output) =>
default;
public ValueTask RequestHaltAsync() =>
default;
public ValueTask QueueClearScopeAsync(string? scopeName = null) =>
default;
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
@@ -9,13 +10,13 @@ namespace Microsoft.Agents.Workflows.UnitTests;
public class StreamingAggregatorsTests
{
private static TResult? ApplyStreamingAggregator<TInput, TResult>(
StreamingAggregator<TInput, TResult> aggregator,
Func<TResult?, TInput, TResult?> aggregator,
IEnumerable<TInput> inputs,
TResult? runningResult = default)
{
foreach (TInput input in inputs)
{
runningResult = aggregator(input, runningResult);
runningResult = aggregator(runningResult, input);
}
return runningResult!;
@@ -24,8 +25,8 @@ public class StreamingAggregatorsTests
[Fact]
public void Test_StreamingAggregators_First()
{
IEnumerable<int> inputs = [1, 2, 3];
StreamingAggregator<int, int> aggregator = StreamingAggregators.First<int>();
IEnumerable<int?> inputs = [1, 2, 3];
Func<int?, int?, int?> aggregator = StreamingAggregators.First<int?>();
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(1);
@@ -39,8 +40,8 @@ public class StreamingAggregatorsTests
[Fact]
public void Test_StreamingAggregators_First_WithConversion()
{
IEnumerable<int> inputs = [2, 4, 6];
StreamingAggregator<int, int> aggregator = StreamingAggregators.First<int, int>(input => input / 2);
IEnumerable<int?> inputs = [2, 4, 6];
Func<int?, int?, int?> aggregator = StreamingAggregators.First<int?, int?>(input => input / 2);
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(1);
@@ -55,7 +56,7 @@ public class StreamingAggregatorsTests
public void Test_StreamingAggregators_Last()
{
IEnumerable<int> inputs = [1, 2, 3];
StreamingAggregator<int, int> aggregator = StreamingAggregators.Last<int>();
Func<int, int, int> aggregator = StreamingAggregators.Last<int>();
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(3);
@@ -70,7 +71,7 @@ public class StreamingAggregatorsTests
public void Test_StreamingAggregators_Last_WithConversion()
{
IEnumerable<int> inputs = [2, 4, 6];
StreamingAggregator<int, int> aggregator = StreamingAggregators.Last<int, int>(input => input / 2);
Func<int, int, int> aggregator = StreamingAggregators.Last<int, int>(input => input / 2);
int? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().Be(3);
@@ -85,7 +86,7 @@ public class StreamingAggregatorsTests
public void Test_StreamingAggregators_Union()
{
IEnumerable<int> inputs = [1, 2, 3];
StreamingAggregator<int, IEnumerable<int>> aggregator = StreamingAggregators.Union<int>();
Func<IEnumerable<int>?, int, IEnumerable<int>?> aggregator = StreamingAggregators.Union<int>();
IEnumerable<int>? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().BeEquivalentTo([1, 2, 3], "Union should accumulate all inputs in order");
@@ -102,7 +103,7 @@ public class StreamingAggregatorsTests
public void Test_StreamingAggregators_Union_WithConversion()
{
IEnumerable<int> inputs = [2, 4, 6];
StreamingAggregator<int, IEnumerable<int>> aggregator = StreamingAggregators.Union<int, int>(input => input / 2);
Func<IEnumerable<int>?, int, IEnumerable<int>?> aggregator = StreamingAggregators.Union<int, int>(input => input / 2);
IEnumerable<int>? runningResult = ApplyStreamingAggregator(aggregator, inputs);
runningResult.Should().BeEquivalentTo([1, 2, 3],
@@ -14,6 +14,12 @@ public class TestRunContext : IRunnerContext
public ValueTask AddEventAsync(WorkflowEvent workflowEvent)
=> runnerContext.AddEventAsync(workflowEvent);
public ValueTask YieldOutputAsync(object output)
=> this.AddEventAsync(new WorkflowOutputEvent(output, executorId));
public ValueTask RequestHaltAsync()
=> this.AddEventAsync(new RequestHaltEvent());
public ValueTask QueueClearScopeAsync(string? scopeName = null)
=> default;
@@ -15,7 +15,7 @@ internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
private readonly HashSet<CancellationToken> _linkedTokens = [];
private CancellationTokenSource _internalCts = new();
protected TestingExecutor(string? id = null, bool loop = false, params Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] actions) : base(id)
protected TestingExecutor(string id, bool loop = false, params Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] actions) : base(id)
{
this._loop = loop;
this._actions = actions;
@@ -20,10 +20,12 @@ internal static partial class ValidationExtensions
prototype.SinkIds.SequenceEqual(actual.SinkIds);
}
public static Expression<Func<TypeId, bool>> CreateValidator(this TypeId prototype)
public static Expression<Func<TypeId, bool>> CreateValidator(this TypeId? prototype)
{
return actual => actual.AssemblyName == prototype.AssemblyName &&
actual.TypeName == prototype.TypeName;
return actual => (prototype == null && actual == null)
|| (prototype != null && actual != null
&& actual.AssemblyName == prototype.AssemblyName
&& actual.TypeName == prototype.TypeName);
}
public static Expression<Func<ExecutorInfo, bool>> CreateValidator(this ExecutorInfo prototype)
@@ -7,14 +7,14 @@ namespace Microsoft.Agents.Workflows.UnitTests;
public partial class WorkflowBuilderSmokeTests
{
private sealed class NoOpExecutor(string? id = null) : Executor(id)
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
}
private sealed class SomeOtherNoOpExecutor(string? id = null) : Executor(id)
private sealed class SomeOtherNoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
@@ -26,7 +26,7 @@ public partial class WorkflowBuilderSmokeTests
{
Workflow workflow = new WorkflowBuilder("start")
.BindExecutor(new NoOpExecutor("start"))
.Build<object>();
.Build();
workflow.StartExecutorId.Should().Be("start");
@@ -41,7 +41,7 @@ public partial class WorkflowBuilderSmokeTests
NoOpExecutor start = new("start");
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(start, start)
.Build<object>();
.Build();
workflow.StartExecutorId.Should().Be("start");
@@ -60,7 +60,7 @@ public partial class WorkflowBuilderSmokeTests
{
return new WorkflowBuilder("start")
.AddEdge(executor1, executor2)
.Build<object>();
.Build();
};
act.Should().Throw<InvalidOperationException>();
@@ -73,7 +73,7 @@ public partial class WorkflowBuilderSmokeTests
Workflow workflow = new WorkflowBuilder("start")
.AddEdge(executor1, executor1)
.Build<object>();
.Build();
workflow.StartExecutorId.Should().Be("start");