chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+63
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step1EntryPoint
|
||||
{
|
||||
public static Workflow WorkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
UppercaseExecutor uppercase = new();
|
||||
ReverseTextExecutor reverse = new();
|
||||
|
||||
WorkflowBuilder builder = new(uppercase);
|
||||
builder.AddEdge(uppercase, reverse).WithOutputFrom(reverse);
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
StreamingRun run = await environment.RunStreamingAsync(WorkflowInstance, input: "Hello, World!").ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorCompleted)
|
||||
{
|
||||
writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class UppercaseExecutor() : Executor<string, string>(nameof(UppercaseExecutor), declareCrossRunShareable: true)
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
|
||||
message.ToUpperInvariant();
|
||||
}
|
||||
|
||||
internal sealed class ReverseTextExecutor() : Executor("ReverseTextExecutor", declareCrossRunShareable: true)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<string, string>(this.HandleAsync));
|
||||
}
|
||||
|
||||
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string result = string.Concat(message.Reverse());
|
||||
|
||||
await context.YieldOutputAsync(result, cancellationToken).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using static Microsoft.Agents.AI.Workflows.Sample.Step1EntryPoint;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step1aEntryPoint
|
||||
{
|
||||
// TODO: Maybe env.CreateRunAsync?
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
Run run = await environment.RunAsync(WorkflowInstance, "Hello, World!").ConfigureAwait(false);
|
||||
|
||||
Assert.Equal(RunStatus.Idle, await run.GetStatusAsync());
|
||||
|
||||
foreach (WorkflowEvent evt in run.NewEvents)
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorCompleted)
|
||||
{
|
||||
writer.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Reflection;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step2EntryPoint
|
||||
{
|
||||
public static Workflow WorkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
string[] spamKeywords = ["spam", "advertisement", "offer"];
|
||||
|
||||
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
|
||||
.WithOutputFrom(respondToMessage, removeSpam)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, string input = "This is a spam message.")
|
||||
{
|
||||
StreamingRun handle = await environment.RunStreamingAsync(WorkflowInstance, input: input).ConfigureAwait(false);
|
||||
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case WorkflowOutputEvent workflowOutputEvt:
|
||||
// The workflow has completed successfully, return the result
|
||||
string workflowResult = workflowOutputEvt.As<string>()!;
|
||||
writer.WriteLine($"Result: {workflowResult}");
|
||||
return workflowResult;
|
||||
case ExecutorCompletedEvent executorCompletedEvt:
|
||||
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
|
||||
break;
|
||||
case WorkflowErrorEvent errorEvent:
|
||||
Assert.Fail($"Workflow failed with error: {errorEvent.Exception}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Workflow failed to yield an output.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords) :
|
||||
ReflectingExecutor<DetectSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<string, bool>
|
||||
{
|
||||
public async ValueTask<bool> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
|
||||
spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0);
|
||||
}
|
||||
|
||||
internal sealed partial class RespondToMessageExecutor(string id) : Executor(id, declareCrossRunShareable: true), IMessageHandler<bool>
|
||||
{
|
||||
public const string ActionResult = "Message processed successfully.";
|
||||
|
||||
[MessageHandler(Yield = [typeof(string)])]
|
||||
public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (message)
|
||||
{
|
||||
// This is SPAM, and should not have been routed here
|
||||
throw new InvalidOperationException("Received a spam message that should not be getting a reply.");
|
||||
}
|
||||
|
||||
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay
|
||||
|
||||
await context.YieldOutputAsync(ActionResult, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed partial class RemoveSpamExecutor(string id) : Executor(id, declareCrossRunShareable: true), IMessageHandler<bool>
|
||||
{
|
||||
public const string ActionResult = "Spam message removed.";
|
||||
|
||||
[MessageHandler(Yield = [typeof(string)])]
|
||||
public async ValueTask HandleAsync(bool message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!message)
|
||||
{
|
||||
// This is NOT SPAM, and should not have been routed here
|
||||
throw new InvalidOperationException("Received a non-spam message that should not be getting removed.");
|
||||
}
|
||||
|
||||
await Task.Delay(1000, cancellationToken).ConfigureAwait(false); // Simulate some processing delay
|
||||
|
||||
await context.YieldOutputAsync(ActionResult, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step3EntryPoint
|
||||
{
|
||||
public static Workflow WorkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
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)
|
||||
.WithOutputFrom(guessNumber)
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask<string> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
StreamingRun run = await environment.RunStreamingAsync(WorkflowInstance, NumberSignal.Init).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case WorkflowOutputEvent workflowOutputEvt:
|
||||
// The workflow has completed successfully, return the result
|
||||
string workflowResult = workflowOutputEvt.As<string>()!;
|
||||
writer.WriteLine($"Result: {workflowResult}");
|
||||
return workflowResult;
|
||||
case ExecutorCompletedEvent executorCompletedEvt:
|
||||
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Workflow failed to yield an output.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record TryCount(int Tries);
|
||||
|
||||
internal sealed record NumberBounds(int LowerBound, int UpperBound)
|
||||
{
|
||||
public int CurrGuess => (this.LowerBound + this.UpperBound) / 2;
|
||||
|
||||
public NumberBounds ForAboveHint() => this with { UpperBound = this.CurrGuess - 1 };
|
||||
public NumberBounds ForBelowHint() => this with { LowerBound = this.CurrGuess + 1 };
|
||||
}
|
||||
|
||||
internal enum NumberSignal
|
||||
{
|
||||
Init,
|
||||
Above,
|
||||
Below,
|
||||
Matched
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class GuessNumberExecutor : Executor
|
||||
{
|
||||
private readonly int _initialLowerBound;
|
||||
private readonly int _initialUpperBound;
|
||||
|
||||
public GuessNumberExecutor(string id, int lowerBound, int upperBound) : base(id, new ExecutorOptions { AutoYieldOutputHandlerResultObject = false }, declareCrossRunShareable: true)
|
||||
{
|
||||
if (lowerBound >= upperBound)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(lowerBound), "Lower bound must be less than upper bound.");
|
||||
}
|
||||
|
||||
this._initialLowerBound = lowerBound;
|
||||
this._initialUpperBound = upperBound;
|
||||
}
|
||||
|
||||
[MessageHandler]
|
||||
public async ValueTask<int> HandleAsync(NumberSignal message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
NumberBounds bounds = await context.ReadStateAsync<NumberBounds>(nameof(NumberBounds), cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? new NumberBounds(this._initialLowerBound, this._initialUpperBound);
|
||||
|
||||
switch (message)
|
||||
{
|
||||
case NumberSignal.Matched:
|
||||
await context.YieldOutputAsync($"Guessed the number: {bounds.CurrGuess}", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case NumberSignal.Above:
|
||||
bounds = bounds.ForAboveHint();
|
||||
break;
|
||||
case NumberSignal.Below:
|
||||
bounds = bounds.ForBelowHint();
|
||||
break;
|
||||
}
|
||||
|
||||
await context.QueueStateUpdateAsync(nameof(NumberBounds), bounds, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return bounds.CurrGuess;
|
||||
}
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(TryCount))]
|
||||
internal sealed partial class JudgeExecutor : Executor
|
||||
{
|
||||
private readonly int _targetNumber;
|
||||
|
||||
public JudgeExecutor(string id, int targetNumber) : base(id, declareCrossRunShareable: true)
|
||||
{
|
||||
this._targetNumber = targetNumber;
|
||||
}
|
||||
|
||||
[MessageHandler]
|
||||
public async ValueTask<NumberSignal> HandleAsync(int message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// This works properly because the default when unset is 0, and we increment before use.
|
||||
int tries = await context.ReadStateAsync<int>("TryCount", cancellationToken: cancellationToken).ConfigureAwait(false) + 1;
|
||||
await context.YieldOutputAsync(new TryCount(tries), cancellationToken);
|
||||
|
||||
return
|
||||
message == this._targetNumber ? NumberSignal.Matched :
|
||||
message < this._targetNumber ? NumberSignal.Below :
|
||||
NumberSignal.Above;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step4EntryPoint
|
||||
{
|
||||
internal const string JudgeId = "Judge";
|
||||
|
||||
public static Workflow CreateWorkflowInstance(out JudgeExecutor judge)
|
||||
{
|
||||
RequestPort guessNumber = RequestPort.Create<NumberSignal, int>("GuessNumber");
|
||||
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)
|
||||
.WithOutputFrom(judge)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static Workflow WorkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
return CreateWorkflowInstance(out _);
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
NumberSignal signal = NumberSignal.Init;
|
||||
string? prompt = UpdatePrompt(null, signal);
|
||||
|
||||
Workflow workflow = WorkflowInstance;
|
||||
StreamingRun handle = await environment.RunStreamingAsync(workflow, NumberSignal.Init).ConfigureAwait(false);
|
||||
|
||||
List<ExternalRequest> requests = [];
|
||||
await foreach (WorkflowEvent evt in handle.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case WorkflowOutputEvent outputEvent:
|
||||
switch (outputEvent.ExecutorId)
|
||||
{
|
||||
case JudgeId:
|
||||
if (outputEvent.Is(out NumberSignal newSignal))
|
||||
{
|
||||
prompt = UpdatePrompt(prompt, signal = newSignal);
|
||||
}
|
||||
else if (!outputEvent.Is<TryCount>())
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case RequestInfoEvent requestInputEvt:
|
||||
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 ExecutorCompletedEvent executorCompletedEvt:
|
||||
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteLine($"Result: {prompt}");
|
||||
return prompt!;
|
||||
}
|
||||
|
||||
private static ExternalResponse ExecuteExternalRequest(
|
||||
ExternalRequest request,
|
||||
Func<string, int> userGuessCallback,
|
||||
string? runningState)
|
||||
{
|
||||
object result = request.PortInfo.PortId switch
|
||||
{
|
||||
"GuessNumber" => userGuessCallback(runningState ?? "Guess the number."),
|
||||
_ => throw new NotSupportedException($"Request {request.PortInfo.PortId} is not supported")
|
||||
};
|
||||
|
||||
return request.CreateResponse(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This converts the incoming <see cref="NumberSignal"/> from the judge to a status text that can be displayed
|
||||
/// to the user.
|
||||
/// </summary>
|
||||
/// <param name="runningResult"></param>
|
||||
/// <param name="signal"></param>
|
||||
/// <returns></returns>
|
||||
internal static string? UpdatePrompt(string? runningResult, NumberSignal signal)
|
||||
{
|
||||
return signal switch
|
||||
{
|
||||
NumberSignal.Matched => "You guessed correctly! You Win!",
|
||||
NumberSignal.Above => "Your guess was too high. Try again.",
|
||||
NumberSignal.Below => "Your guess was too low. Try again.",
|
||||
|
||||
_ => runningResult
|
||||
};
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step5EntryPoint
|
||||
{
|
||||
public static async ValueTask<string> RunAsync(TextWriter writer, Func<string, int> userGuessCallback, InProcessExecutionEnvironment environment, bool rehydrateToRestore = false, CheckpointManager? checkpointManager = null)
|
||||
{
|
||||
Dictionary<CheckpointInfo, (NumberSignal signal, string? prompt)> checkpointedOutputs = [];
|
||||
|
||||
NumberSignal signal = NumberSignal.Init;
|
||||
string? prompt = Step4EntryPoint.UpdatePrompt(null, signal);
|
||||
|
||||
checkpointManager ??= CheckpointManager.Default;
|
||||
|
||||
Workflow workflow = Step4EntryPoint.CreateWorkflowInstance(out JudgeExecutor judge);
|
||||
|
||||
StreamingRun handle =
|
||||
await environment.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, NumberSignal.Init)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
List<CheckpointInfo> checkpoints = [];
|
||||
CancellationTokenSource cancellationSource = new();
|
||||
|
||||
string? result = await RunStreamToHaltOrMaxStepAsync(maxStep: 6).ConfigureAwait(false);
|
||||
|
||||
result.Should().BeNull();
|
||||
checkpoints.Should().HaveCount(6, "we should have two checkpoints, one for each step");
|
||||
|
||||
CheckpointInfo targetCheckpoint = checkpoints[2];
|
||||
|
||||
Console.WriteLine($"Restoring to checkpoint {targetCheckpoint} from session {targetCheckpoint.SessionId}");
|
||||
if (rehydrateToRestore)
|
||||
{
|
||||
await handle.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
handle = await environment.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, targetCheckpoint, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await handle.RestoreCheckpointAsync(checkpoints[2], CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
(signal, prompt) = checkpointedOutputs[targetCheckpoint];
|
||||
|
||||
cancellationSource.Dispose();
|
||||
cancellationSource = new();
|
||||
|
||||
checkpoints.Clear();
|
||||
result = await RunStreamToHaltOrMaxStepAsync().ConfigureAwait(false);
|
||||
|
||||
result.Should().NotBeNull();
|
||||
|
||||
// Depending on the timing of the response with respect to the underlying workflow
|
||||
// we may end up with an extra superstep in between.
|
||||
checkpoints.Should().HaveCountGreaterThanOrEqualTo(6)
|
||||
.And.HaveCountLessThanOrEqualTo(7);
|
||||
|
||||
cancellationSource.Dispose();
|
||||
|
||||
return result;
|
||||
|
||||
async ValueTask<string?> RunStreamToHaltOrMaxStepAsync(int? maxStep = null)
|
||||
{
|
||||
List<ExternalRequest> requests = [];
|
||||
await foreach (WorkflowEvent evt in handle.WatchStreamAsync(cancellationSource.Token).ConfigureAwait(false))
|
||||
{
|
||||
Console.WriteLine($"!!! Processing event: {evt}");
|
||||
switch (evt)
|
||||
{
|
||||
case WorkflowOutputEvent outputEvent:
|
||||
switch (outputEvent.ExecutorId)
|
||||
{
|
||||
case Step4EntryPoint.JudgeId:
|
||||
if (outputEvent.Is(out NumberSignal newSignal))
|
||||
{
|
||||
prompt = Step4EntryPoint.UpdatePrompt(prompt, signal = newSignal);
|
||||
}
|
||||
// TODO: We should make some well-defined way to avoid this kind of
|
||||
// if/elseif chain, because .Is() chains are slow
|
||||
else if (!outputEvent.Is<TryCount>())
|
||||
{
|
||||
throw new InvalidOperationException($"Unexpected output type {outputEvent.Data!.GetType()}");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case RequestInfoEvent requestInputEvt:
|
||||
Console.WriteLine($"!!! Queuing request: {requestInputEvt.Request}");
|
||||
requests.Add(requestInputEvt.Request);
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompletedEvt:
|
||||
Console.WriteLine($"*** Step {stepCompletedEvt.StepNumber} completed.");
|
||||
CheckpointInfo? checkpoint = stepCompletedEvt.CompletionInfo!.Checkpoint;
|
||||
Console.WriteLine($"*** Checkpoint: {checkpoint}");
|
||||
if (checkpoint is not null)
|
||||
{
|
||||
checkpoints.Add(checkpoint);
|
||||
|
||||
checkpointedOutputs[checkpoint] = (signal, prompt);
|
||||
}
|
||||
|
||||
if (maxStep.HasValue && stepCompletedEvt.StepNumber >= maxStep.Value - 1)
|
||||
{
|
||||
Console.WriteLine($"*** Max step {maxStep} reached, cancelling.");
|
||||
cancellationSource.Cancel();
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.WriteLine($"*** Processing {requests.Count} queued requests.");
|
||||
foreach (ExternalRequest request in requests)
|
||||
{
|
||||
ExternalResponse response = ExecuteExternalRequest(request, userGuessCallback, prompt);
|
||||
Console.WriteLine($"!!! Sending response: {response}");
|
||||
await handle.SendResponseAsync(response).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
requests.Clear();
|
||||
|
||||
Console.WriteLine("*** Completed processing requests.");
|
||||
|
||||
break;
|
||||
|
||||
case ExecutorCompletedEvent executorCompleteEvt:
|
||||
writer.WriteLine($"'{executorCompleteEvt.ExecutorId}: {executorCompleteEvt.Data}");
|
||||
break;
|
||||
}
|
||||
Console.WriteLine($"!!! Completed processing event: {evt.GetType()}");
|
||||
}
|
||||
|
||||
if (cancellationSource.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
writer.WriteLine($"Result: {prompt}");
|
||||
return prompt!;
|
||||
}
|
||||
}
|
||||
|
||||
private static ExternalResponse ExecuteExternalRequest(
|
||||
ExternalRequest request,
|
||||
Func<string, int> userGuessCallback,
|
||||
string? runningState)
|
||||
{
|
||||
object result = request.PortInfo.PortId switch
|
||||
{
|
||||
"GuessNumber" => userGuessCallback(runningState ?? "Guess the number."),
|
||||
_ => throw new NotSupportedException($"Request {request.PortInfo.PortId} is not supported")
|
||||
};
|
||||
|
||||
return request.CreateResponse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step6EntryPoint
|
||||
{
|
||||
public const string EchoAgentId = "echo";
|
||||
public const string EchoPrefix = "You said: ";
|
||||
|
||||
public static Workflow CreateWorkflow(int maxTurns) =>
|
||||
AgentWorkflowBuilder
|
||||
.CreateGroupChatBuilderWith(agents => new RoundRobinGroupChatManager(agents) { MaximumIterationCount = maxTurns })
|
||||
.AddParticipants(new HelloAgent(), new TestEchoAgent(id: EchoAgentId, prefix: EchoPrefix))
|
||||
.Build();
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2)
|
||||
{
|
||||
Workflow workflow = CreateWorkflow(maxSteps);
|
||||
|
||||
StreamingRun run = await environment.RunStreamingAsync(workflow, Array.Empty<ChatMessage>())
|
||||
.ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (evt is ExecutorCompletedEvent executorCompleted)
|
||||
{
|
||||
Debug.WriteLine($"{executorCompleted.ExecutorId}: {executorCompleted.Data}");
|
||||
}
|
||||
else if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
AgentResponse response = update.AsResponse();
|
||||
|
||||
foreach (ChatMessage message in response.Messages)
|
||||
{
|
||||
writer.WriteLine($"{update.ExecutorId}: {message.Text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
|
||||
{
|
||||
public const string Greeting = "Hello World!";
|
||||
public const string DefaultId = nameof(HelloAgent);
|
||||
|
||||
protected override string? IdCore => id;
|
||||
public override string? Name => id;
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new HelloAgentSession());
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> new(new HelloAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
|
||||
protected override async Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<AgentResponseUpdate> update = [
|
||||
await this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
|
||||
.SingleAsync(cancellationToken)
|
||||
.ConfigureAwait(false)];
|
||||
|
||||
return update.ToAgentResponse();
|
||||
}
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return new(ChatRole.Assistant, "Hello World!")
|
||||
{
|
||||
AgentId = this.Id,
|
||||
AuthorName = this.Name,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HelloAgentSession() : AgentSession();
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step7EntryPoint
|
||||
{
|
||||
public static string EchoAgentId => Step6EntryPoint.EchoAgentId;
|
||||
public static string EchoPrefix => Step6EntryPoint.EchoPrefix;
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, int maxSteps = 2, int numIterations = 2)
|
||||
{
|
||||
Workflow workflow = Step6EntryPoint.CreateWorkflow(maxSteps);
|
||||
|
||||
AIAgent agent = workflow.AsAIAgent("group-chat-agent", "Group Chat Agent");
|
||||
|
||||
for (int i = 0; i < numIterations; i++)
|
||||
{
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(session).ConfigureAwait(false))
|
||||
{
|
||||
if (update.RawRepresentation is WorkflowEvent)
|
||||
{
|
||||
// Skip workflow status updates
|
||||
continue;
|
||||
}
|
||||
string updateText = $"{update.AuthorName
|
||||
?? update.AgentId
|
||||
?? update.Role.ToString()
|
||||
?? ChatRole.Assistant.ToString()}: {update.Text}";
|
||||
writer.WriteLine(updateText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal sealed record class TextProcessingRequest(string Text, string TaskId);
|
||||
internal sealed record class TextProcessingResult(string TaskId, string Text, int WordCount, int ChatCount);
|
||||
|
||||
internal static partial class Step8EntryPoint
|
||||
{
|
||||
public static List<string> TextsToProcess => [
|
||||
"Hello world! This is a simple test.",
|
||||
"Python is a powerful programming language used for many applications.",
|
||||
"Short text.",
|
||||
"This is a longer text with multiple sentences. It contains more words and characters. We use it to test our text processing workflow.",
|
||||
"",
|
||||
" Spaces around text ",
|
||||
];
|
||||
|
||||
public static async ValueTask<List<TextProcessingResult>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment, List<string> textsToProcess)
|
||||
{
|
||||
Func<TextProcessingRequest, IWorkflowContext, CancellationToken, ValueTask> processTextAsyncFunc = ProcessTextAsync;
|
||||
|
||||
ExecutorBinding processText = processTextAsyncFunc.BindAsExecutor("TextProcessor", threadsafe: true);
|
||||
|
||||
Workflow subWorkflow = new WorkflowBuilder(processText).WithOutputFrom(processText).Build();
|
||||
|
||||
ExecutorBinding textProcessor = subWorkflow.BindAsExecutor("TextProcessor");
|
||||
Func<string, string, ValueTask<Executor>> createOrchestrator = (id, _) => new(new TextProcessingOrchestrator(id));
|
||||
var orchestrator = createOrchestrator.BindExecutor();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(orchestrator)
|
||||
.AddEdge(orchestrator, textProcessor)
|
||||
.AddEdge(textProcessor, orchestrator)
|
||||
.WithOutputFrom(orchestrator)
|
||||
.Build();
|
||||
|
||||
Run workflowRun = await environment.RunAsync(workflow, textsToProcess);
|
||||
|
||||
RunStatus status = await workflowRun.GetStatusAsync();
|
||||
List<Exception?> errors = workflowRun.OutgoingEvents.OfType<WorkflowErrorEvent>()
|
||||
.Select(errorEvent => errorEvent.Exception)
|
||||
.Where(e => e is not null).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
StringBuilder errorBuilder = new();
|
||||
errorBuilder.AppendLine($"Workflow execution failed. ({errors.Count} errors.):");
|
||||
|
||||
foreach (Exception? error in errors)
|
||||
{
|
||||
errorBuilder.Append('\t').AppendLine(error!.ToString());
|
||||
}
|
||||
|
||||
Assert.Fail(errorBuilder.ToString());
|
||||
}
|
||||
|
||||
status.Should().Be(RunStatus.Idle);
|
||||
|
||||
WorkflowOutputEvent? maybeOutput = workflowRun.OutgoingEvents.OfType<WorkflowOutputEvent>()
|
||||
.SingleOrDefault();
|
||||
|
||||
maybeOutput.Should().NotBeNull("the workflow should have produced an output event");
|
||||
List<TextProcessingResult>? maybeResults = maybeOutput.As<List<TextProcessingResult>>();
|
||||
|
||||
maybeResults.Should().NotBeNull("the output event should contain the results");
|
||||
List<TextProcessingResult> results = maybeResults;
|
||||
|
||||
results.Sort((left, right) => StringComparer.Ordinal.Compare(left.TaskId, right.TaskId));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
[YieldsOutput(typeof(TextProcessingResult))]
|
||||
private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
int wordCount = 0;
|
||||
int charCount = 0;
|
||||
|
||||
if (request.Text.Length != 0)
|
||||
{
|
||||
wordCount = request.Text.Split([' '], StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
charCount = request.Text.Length;
|
||||
}
|
||||
|
||||
return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken);
|
||||
}
|
||||
|
||||
private sealed partial class TextProcessingOrchestrator(string id)
|
||||
: StatefulExecutor<TextProcessingOrchestrator.State>(id, () => new(), declareCrossRunShareable: false)
|
||||
{
|
||||
internal sealed class State
|
||||
{
|
||||
public List<TextProcessingResult> Results { get; } = [];
|
||||
public HashSet<string> PendingTaskIds { get; } = [];
|
||||
|
||||
public bool IsComplete => this.PendingTaskIds.Count == 0;
|
||||
|
||||
public void AddPending(string taskId) => this.PendingTaskIds.Add(taskId);
|
||||
public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId);
|
||||
}
|
||||
|
||||
[MessageHandler(Send = [typeof(TextProcessingRequest)])]
|
||||
public async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<State?> QueueProcessingTasksAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (TextProcessingRequest request in texts.Select((value, index) => new TextProcessingRequest(Text: value, TaskId: $"Task{index}")))
|
||||
{
|
||||
state.PendingTaskIds.Add(request.TaskId);
|
||||
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
[MessageHandler(Yield = [typeof(List<TextProcessingResult>)])]
|
||||
public async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<State?> CollectResultAndCheckCompletionAsync(State state, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (state.PendingTaskIds.Remove(result.TaskId))
|
||||
{
|
||||
state.Results.Add(result);
|
||||
}
|
||||
|
||||
if (state.PendingTaskIds.Count == 0)
|
||||
{
|
||||
await context.YieldOutputAsync(state.Results, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal sealed record class UserRequest(string RequestType, string Type, int Amount, string Id, string? Priority = null, string? PolicyType = null)
|
||||
{
|
||||
internal static int RequestCount;
|
||||
|
||||
public static string CreateId()
|
||||
{
|
||||
string result = Interlocked.Increment(ref RequestCount).ToString();
|
||||
Console.Error.WriteLine($"Got Id: {result}");
|
||||
return result;
|
||||
}
|
||||
|
||||
public static UserRequest CreateResourceRequest(string resourceType = "cpu", int amount = 1, string priority = "normal")
|
||||
{
|
||||
UserRequest request = new("resource", resourceType, amount, Priority: priority, Id: CreateId());
|
||||
Console.Error.WriteLine($"\t{request}");
|
||||
return request;
|
||||
}
|
||||
|
||||
public static UserRequest CreatePolicyCheckRequest(string resourceType = "cpu", int amount = 1, string policyType = "quota")
|
||||
{
|
||||
UserRequest request = new("policy", resourceType, amount, PolicyType: policyType, Id: CreateId());
|
||||
Console.Error.WriteLine($"\t{request}");
|
||||
return request;
|
||||
}
|
||||
|
||||
public ResourceResponse CreateResourceResponse(int allocated, string source)
|
||||
=> new(this.Id, this.Type, allocated, source);
|
||||
|
||||
public PolicyResponse CreatePolicyResponse(bool approved, string reason)
|
||||
=> new(this.Id, approved, reason);
|
||||
|
||||
public RequestFinished CreateExpected(ResourceResponse response)
|
||||
=> new(this.Id, RequestType: "resource", ResourceResponse: response with { Id = this.Id });
|
||||
|
||||
public RequestFinished CreateExpectedResourceResponse(int allocated, string source)
|
||||
=> this.CreateExpected(this.CreateResourceResponse(allocated, source));
|
||||
|
||||
public RequestFinished CreateExpected(PolicyResponse response)
|
||||
=> new(this.Id, RequestType: "policy", PolicyResponse: response with { Id = this.Id });
|
||||
|
||||
public RequestFinished CreateExpectedPolicyResponse(bool approved, string reason)
|
||||
=> this.CreateExpected(this.CreatePolicyResponse(approved, reason));
|
||||
}
|
||||
|
||||
internal sealed record class ResourceRequest(string Id, string ResourceType = "cpu", int Amount = 1, string Priority = "normal");
|
||||
internal sealed record class PolicyCheckRequest(string Id, string ResourceType, int Amount = 0, string PolicyType = "quota");
|
||||
internal sealed record class ResourceResponse(string Id, string ResourceType, int Allocated, string Source);
|
||||
internal sealed record class PolicyResponse(string Id, bool Approved, string Reason);
|
||||
internal sealed record class RequestFinished(string Id, string RequestType, ResourceResponse? ResourceResponse = null, PolicyResponse? PolicyResponse = null);
|
||||
|
||||
internal static class Step9EntryPoint
|
||||
{
|
||||
public static WorkflowBuilder AddPassthroughRequestHandler<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, ExecutorBinding filter, string? id = null)
|
||||
{
|
||||
id ??= typeof(TRequest).Name;
|
||||
|
||||
var requestPort = RequestPort.Create<TRequest, TResponse>(id);
|
||||
|
||||
return builder.ForwardMessage<ExternalRequest>(source, targets: [filter], condition: message => message.IsDataOfType<TRequest>())
|
||||
.ForwardMessage<ExternalRequest>(filter, targets: [requestPort], condition: message => message.IsDataOfType<TRequest>())
|
||||
.ForwardMessage<ExternalResponse>(requestPort, targets: [filter], condition: message => message.IsDataOfType<TResponse>())
|
||||
.ForwardMessage<ExternalResponse>(filter, targets: [source], condition: message => message.IsDataOfType<TResponse>());
|
||||
}
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string? id = null)
|
||||
=> builder.AddExternalRequest(source, out RequestPort<TRequest, TResponse> _, id);
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, out RequestPort<TRequest, TResponse> inputPort, string? id = null)
|
||||
{
|
||||
id ??= $"{source.Id}.Requests[{typeof(TRequest).Name}=>{typeof(TResponse).Name}]";
|
||||
|
||||
inputPort = RequestPort.Create<TRequest, TResponse>(id);
|
||||
|
||||
return builder.AddExternalRequest(source, inputPort);
|
||||
}
|
||||
|
||||
public static WorkflowBuilder AddExternalRequest<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, RequestPort<TRequest, TResponse> inputPort)
|
||||
{
|
||||
return builder.ForwardMessage<TRequest>(source, [inputPort])
|
||||
.ForwardMessage<ExternalRequest>(source, [inputPort])
|
||||
.ForwardMessage<TResponse>(inputPort, [source])
|
||||
.ForwardMessage<ExternalResponse>(inputPort, [source]);
|
||||
}
|
||||
|
||||
public static Workflow CreateSubWorkflow()
|
||||
{
|
||||
ResourceRequestor requestor = new();
|
||||
|
||||
return new WorkflowBuilder(requestor)
|
||||
.AddExternalRequest<ResourceRequest, ResourceResponse>(source: requestor)
|
||||
.AddExternalRequest<PolicyCheckRequest, PolicyResponse>(source: requestor)
|
||||
.WithOutputFrom(requestor)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static Workflow CreateWorkflow()
|
||||
{
|
||||
Coordinator coordinator = new();
|
||||
ResourceCache cache = new();
|
||||
QuotaPolicyEngine policyEngine = new();
|
||||
ExecutorBinding subworkflow = CreateSubWorkflow().BindAsExecutor("ResourceWorkflow");
|
||||
|
||||
return new WorkflowBuilder(coordinator)
|
||||
.AddChain(coordinator, [subworkflow, coordinator], allowRepetition: true)
|
||||
.AddPassthroughRequestHandler<ResourceRequest, ResourceResponse>(subworkflow, cache)
|
||||
.AddPassthroughRequestHandler<PolicyCheckRequest, PolicyResponse>(subworkflow, policyEngine)
|
||||
.WithOutputFrom(coordinator)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static Workflow WorkflowInstance => CreateWorkflow();
|
||||
|
||||
public static UserRequest ResourceHitRequest1 = UserRequest.CreateResourceRequest(resourceType: "cpu", amount: 2, priority: "normal");
|
||||
public static RequestFinished ResourceHitResponse1 = ResourceHitRequest1.CreateExpectedResourceResponse(allocated: 2, "cache");
|
||||
|
||||
public static UserRequest ResourceHitRequest2 = UserRequest.CreateResourceRequest(resourceType: "memory", amount: 15, priority: "normal");
|
||||
public static RequestFinished ResourceHitResponse2 = ResourceHitRequest2.CreateExpectedResourceResponse(allocated: 15, "cache");
|
||||
|
||||
public static UserRequest PolicyHitRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 3, policyType: "quota");
|
||||
public static RequestFinished PolicyHitResponse1 = PolicyHitRequest1.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (5)");
|
||||
|
||||
public static UserRequest PolicyHitRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "disk", amount: 500, policyType: "quota");
|
||||
public static RequestFinished PolicyHitResponse2 = PolicyHitRequest2.CreateExpectedPolicyResponse(approved: true, reason: "Within quota (1000)");
|
||||
|
||||
public static UserRequest ResourceMissRequest = UserRequest.CreateResourceRequest(resourceType: "gpu", amount: 2, priority: "high");
|
||||
public static RequestFinished ResourceMissResponse = ResourceMissRequest.CreateExpectedResourceResponse(allocated: 1, "external");
|
||||
|
||||
public static UserRequest PolicyMissRequest1 = UserRequest.CreatePolicyCheckRequest(resourceType: "memory", amount: 100, policyType: "quota");
|
||||
public static RequestFinished PolicyMissResponse1 = PolicyMissRequest1.CreateExpectedPolicyResponse(approved: false, reason: "External Rejection");
|
||||
|
||||
public static UserRequest PolicyMissRequest2 = UserRequest.CreatePolicyCheckRequest(resourceType: "cpu", amount: 1, policyType: "security");
|
||||
public static RequestFinished PolicyMissResponse2 = PolicyMissRequest2.CreateExpectedPolicyResponse(approved: true, reason: "External Approval");
|
||||
|
||||
public static HashSet<string> PolicyMissIds = [PolicyMissRequest1.Id, PolicyMissRequest2.Id];
|
||||
public static HashSet<string> ResourceMissIds = [ResourceMissRequest.Id];
|
||||
|
||||
public static Dictionary<string, RequestFinished> Part1FinishedResponses = new()
|
||||
{
|
||||
{ ResourceHitRequest1.Id, ResourceHitResponse1 },
|
||||
{ ResourceHitRequest2.Id, ResourceHitResponse2 },
|
||||
|
||||
{ PolicyHitRequest1.Id, PolicyHitResponse1 },
|
||||
{ PolicyHitRequest2.Id, PolicyHitResponse2 },
|
||||
};
|
||||
|
||||
public static Dictionary<string, RequestFinished> Part2FinishedResponses = new()
|
||||
{
|
||||
{ ResourceMissRequest.Id, ResourceMissResponse},
|
||||
|
||||
{ PolicyMissRequest1.Id, PolicyMissResponse1 },
|
||||
{ PolicyMissRequest2.Id, PolicyMissResponse2 },
|
||||
};
|
||||
|
||||
public static UserRequest[] RequestsToProcess => [
|
||||
ResourceHitRequest1,
|
||||
PolicyHitRequest1,
|
||||
ResourceHitRequest2,
|
||||
PolicyMissRequest1, // miss
|
||||
ResourceMissRequest, // miss
|
||||
PolicyHitRequest2,
|
||||
PolicyMissRequest2, // miss
|
||||
];
|
||||
|
||||
public static List<RequestFinished> ExpectedResponsesPart1 =>
|
||||
[.. RequestsToProcess.Where(request => Part1FinishedResponses.ContainsKey(request.Id))
|
||||
.Select(request => Part1FinishedResponses[request.Id])
|
||||
.OrderBy(request => request.Id)];
|
||||
|
||||
public static RequestFinished[] ExpectedResponsesPart2 =>
|
||||
[.. RequestsToProcess.Where(request => Part2FinishedResponses.ContainsKey(request.Id))
|
||||
.Select(request => Part2FinishedResponses[request.Id])
|
||||
.OrderBy(request => request.Id)];
|
||||
|
||||
public static async ValueTask<List<RequestFinished>> RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
RunStatus runStatus;
|
||||
List<RequestFinished> results = [];
|
||||
|
||||
Run workflowRun = await environment.RunAsync(WorkflowInstance, RequestsToProcess.ToList());
|
||||
|
||||
RunStatus part1Status = ExpectedResponsesPart2.Length > 0 ? RunStatus.PendingRequests : RunStatus.Idle;
|
||||
runStatus = await workflowRun.GetStatusAsync();
|
||||
runStatus.Should().Be(part1Status);
|
||||
|
||||
List<RequestFinished> finishedRequests = [];
|
||||
List<ExternalRequest> resourceRequests = [];
|
||||
List<ExternalRequest> policyRequests = [];
|
||||
|
||||
foreach (WorkflowEvent evt in workflowRun.NewEvents)
|
||||
{
|
||||
if (evt is WorkflowOutputEvent outputEvent && outputEvent.Data is RequestFinished finishedRequest)
|
||||
{
|
||||
finishedRequests.Add(finishedRequest);
|
||||
}
|
||||
else if (evt is RequestInfoEvent requestInfoEvent)
|
||||
{
|
||||
if (requestInfoEvent.Request.IsDataOfType<ResourceRequest>())
|
||||
{
|
||||
resourceRequests.Add(requestInfoEvent.Request);
|
||||
}
|
||||
else if (requestInfoEvent.Request.IsDataOfType<PolicyCheckRequest>())
|
||||
{
|
||||
policyRequests.Add(requestInfoEvent.Request);
|
||||
}
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent error)
|
||||
{
|
||||
Assert.Fail(((Exception)error.Data!).ToString());
|
||||
Console.Error.WriteLine(error.Data);
|
||||
}
|
||||
}
|
||||
|
||||
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
|
||||
finishedRequests.Should().HaveCount(ExpectedResponsesPart1.Count)
|
||||
.And.ContainInOrder(ExpectedResponsesPart1);
|
||||
|
||||
int externalResourceRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.ResourceResponse != null);
|
||||
int externalPolicyRequests = ExpectedResponsesPart2.Count(finishedRequest => finishedRequest.PolicyResponse != null);
|
||||
|
||||
resourceRequests.Should().HaveCount(externalResourceRequests);
|
||||
policyRequests.Should().HaveCount(externalPolicyRequests);
|
||||
|
||||
List<ExternalResponse> responses = [];
|
||||
|
||||
foreach (ExternalRequest request in resourceRequests)
|
||||
{
|
||||
ResourceRequest resourceRequest = request.Data.As<ResourceRequest>()!;
|
||||
resourceRequest.Id.Should().BeOneOf(ResourceMissIds);
|
||||
responses.Add(request.CreateResponse(Part2FinishedResponses[resourceRequest.Id].ResourceResponse!));
|
||||
}
|
||||
|
||||
foreach (ExternalRequest request in policyRequests)
|
||||
{
|
||||
PolicyCheckRequest policyRequest = request.Data.As<PolicyCheckRequest>()!;
|
||||
policyRequest.Id.Should().BeOneOf(PolicyMissIds);
|
||||
responses.Add(request.CreateResponse(Part2FinishedResponses[policyRequest.Id].PolicyResponse!));
|
||||
}
|
||||
|
||||
if (ExpectedResponsesPart2.Length == 0)
|
||||
{
|
||||
responses.Should().BeEmpty();
|
||||
return results;
|
||||
}
|
||||
|
||||
await workflowRun.ResumeAsync(responses: responses).ConfigureAwait(false);
|
||||
runStatus = await workflowRun.GetStatusAsync();
|
||||
List<Exception?> errors = workflowRun.OutgoingEvents.OfType<WorkflowErrorEvent>()
|
||||
.Select(errorEvent => errorEvent.Exception)
|
||||
.Where(e => e is not null).ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
StringBuilder errorBuilder = new();
|
||||
errorBuilder.AppendLine($"Workflow execution failed. ({errors.Count} errors.):");
|
||||
|
||||
foreach (Exception? error in errors)
|
||||
{
|
||||
errorBuilder.Append('\t').AppendLine(error!.ToString());
|
||||
}
|
||||
|
||||
Assert.Fail(errorBuilder.ToString());
|
||||
}
|
||||
|
||||
runStatus.Should().Be(RunStatus.Idle);
|
||||
|
||||
results = finishedRequests;
|
||||
|
||||
finishedRequests = workflowRun.NewEvents.OfType<WorkflowOutputEvent>()
|
||||
.Select(outputEvent => outputEvent.Data)
|
||||
.Where(value => value is not null)
|
||||
.OfType<RequestFinished>()
|
||||
.ToList();
|
||||
|
||||
finishedRequests.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
|
||||
finishedRequests.Should().HaveCount(ExpectedResponsesPart2.Length)
|
||||
.And.ContainInOrder(ExpectedResponsesPart2);
|
||||
|
||||
results.AddRange(finishedRequests);
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true)
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
|
||||
.SendsMessage<ResourceRequest>()
|
||||
.SendsMessage<PolicyCheckRequest>()
|
||||
.YieldsOutput<RequestFinished>();
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
routeBuilder.AddHandler<List<UserRequest>>(this.RequestResourcesAsync)
|
||||
.AddHandler<UserRequest>(InvokeResourceRequestAsync)
|
||||
.AddHandler<ResourceResponse>(this.HandleResponseAsync)
|
||||
.AddHandler<PolicyResponse>(this.HandleResponseAsync);
|
||||
|
||||
// For some reason, using a lambda here causes the analyzer to generate a spurious
|
||||
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
|
||||
// to a variable, or passing it to another method"
|
||||
ValueTask InvokeResourceRequestAsync(UserRequest request, IWorkflowContext context)
|
||||
=> this.RequestResourcesAsync([request], context);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask RequestResourcesAsync(List<UserRequest> requests, IWorkflowContext context)
|
||||
{
|
||||
foreach (UserRequest request in requests)
|
||||
{
|
||||
switch (request.RequestType)
|
||||
{
|
||||
case "resource":
|
||||
await context.SendMessageAsync(new ResourceRequest(Id: request.Id, ResourceType: request.Type, Amount: request.Amount, Priority: request.Priority ?? "normal"))
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
case "policy":
|
||||
await context.SendMessageAsync(new PolicyCheckRequest(Id: request.Id, PolicyType: request.PolicyType ?? "quota", ResourceType: request.Type, Amount: request.Amount))
|
||||
.ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask HandleResponseAsync(ResourceResponse response, IWorkflowContext context)
|
||||
{
|
||||
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "resource", ResourceResponse: response));
|
||||
}
|
||||
|
||||
private async ValueTask HandleResponseAsync(PolicyResponse response, IWorkflowContext context)
|
||||
{
|
||||
await context.YieldOutputAsync(new RequestFinished(response.Id, RequestType: "policy", PolicyResponse: response));
|
||||
}
|
||||
}
|
||||
internal sealed class ResourceCache()
|
||||
: StatefulExecutor<Dictionary<string, int>>(nameof(ResourceCache),
|
||||
InitializeResourceCache,
|
||||
declareCrossRunShareable: true)
|
||||
{
|
||||
private static Dictionary<string, int> InitializeResourceCache()
|
||||
=> new()
|
||||
{
|
||||
["cpu"] = 10,
|
||||
["memory"] = 50,
|
||||
["disk"] = 100,
|
||||
};
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
// Note the disbalance here - we could also handle ExternalResponse here instead, but we would have
|
||||
// to do the exact same type check on it, so we might as well handle
|
||||
routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
|
||||
.AddHandler<ExternalResponse>(this.CollectResultAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request.TryGetDataAs(out ResourceRequest? resourceRequest))
|
||||
{
|
||||
ResourceResponse? response = await this.TryHandleResourceRequestAsync(resourceRequest, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response != null)
|
||||
{
|
||||
await context.SendMessageAsync(request.CreateResponse(response), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cache does not have enough resources, forward the request to the external system
|
||||
await context.SendMessageAsync(request, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<ResourceResponse?> TryHandleResourceRequestAsync(ResourceRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.Error.WriteLine($"Handling Resource Request {request.Id}");
|
||||
|
||||
Dictionary<string, int> availableResources = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Console.Error.WriteLine($"Available Resources: {availableResources}");
|
||||
|
||||
try
|
||||
{
|
||||
if (availableResources.TryGetValue(request.ResourceType, out int available) && available >= request.Amount)
|
||||
{
|
||||
// Cache has enough resources, allocate from cache
|
||||
availableResources[request.ResourceType] -= request.Amount;
|
||||
|
||||
Console.Error.WriteLine($"Handled Resource Request {request.Id}");
|
||||
return new(request.Id, request.ResourceType, request.Amount, Source: "cache");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this.QueueStateUpdateAsync(availableResources, context, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Could not handle Resource Request {request.Id}");
|
||||
return null;
|
||||
}
|
||||
|
||||
private ValueTask CollectResultAsync(ExternalResponse response, IWorkflowContext context)
|
||||
{
|
||||
if (response.IsDataOfType<ResourceResponse>())
|
||||
{
|
||||
// Normally we'd update the cache according to whatever logic we want here.
|
||||
return context.SendMessageAsync(response);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class QuotaPolicyEngine()
|
||||
: StatefulExecutor<Dictionary<string, int>>(nameof(QuotaPolicyEngine),
|
||||
InitializePolicyQuotas,
|
||||
declareCrossRunShareable: true)
|
||||
{
|
||||
private static Dictionary<string, int> InitializePolicyQuotas()
|
||||
=> new()
|
||||
{
|
||||
["cpu"] = 5,
|
||||
["memory"] = 20,
|
||||
["disk"] = 1000,
|
||||
};
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(ConfigureRoutes);
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
// Note the disbalance here - we could also handle ExternalResponse here instead, but we would have
|
||||
// to do the exact same type check on it, so we might as well handle
|
||||
routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
|
||||
.AddHandler<ExternalResponse>(this.CollectAndForwardAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask UnwrapAndHandleRequestAsync(ExternalRequest request, IWorkflowContext context)
|
||||
{
|
||||
if (request.TryGetDataAs(out PolicyCheckRequest? policyRquest))
|
||||
{
|
||||
PolicyResponse? response = await this.TryHandlePolicyCheckRequestAsync(policyRquest, context)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (response != null)
|
||||
{
|
||||
await context.SendMessageAsync(request.CreateResponse(response)).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// QuotaPolicyEngine cannot approve the request, forward to external system
|
||||
await context.SendMessageAsync(request).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<PolicyResponse?> TryHandlePolicyCheckRequestAsync(PolicyCheckRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.Error.WriteLine($"Handling Policy Request {request.Id}");
|
||||
|
||||
Dictionary<string, int> quotas = await this.ReadStateAsync(context, cancellationToken: cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Console.Error.WriteLine($"Policy Quotas: {quotas}");
|
||||
|
||||
try
|
||||
{
|
||||
if (request.PolicyType == "quota" &&
|
||||
quotas.TryGetValue(request.ResourceType, out int quota) &&
|
||||
request.Amount <= quota)
|
||||
{
|
||||
Console.Error.WriteLine($"Handled Policy Request {request.Id}");
|
||||
|
||||
return new(request.Id, Approved: true, Reason: $"Within quota ({quota})");
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"Could not handle Policy Request {request.Id}");
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await this.QueueStateUpdateAsync(quotas, context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
private ValueTask CollectAndForwardAsync(ExternalResponse response, IWorkflowContext context)
|
||||
{
|
||||
if (response.IsDataOfType<PolicyResponse>())
|
||||
{
|
||||
return context.SendMessageAsync(response);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCrossRunShareable: true)
|
||||
{
|
||||
private const string StateKey = nameof(StateKey);
|
||||
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
|
||||
.SendsMessage<UserRequest>()
|
||||
.YieldsOutput<RequestFinished>();
|
||||
|
||||
void ConfigureRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
routeBuilder.AddHandler<List<UserRequest>>(this.StartAsync)
|
||||
.AddHandler<UserRequest>(InvokeStartAsync)
|
||||
.AddHandler<RequestFinished>(this.HandleFinishedRequestAsync);
|
||||
|
||||
// For some reason, using a lambda here causes the analyzer to generate a spurious
|
||||
// VSTHRD110: "Observe the awaitable result of this method call by awaiting it, assigning
|
||||
// to a variable, or passing it to another method"
|
||||
ValueTask InvokeStartAsync(UserRequest request, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
=> this.StartAsync([request], context, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask HandleFinishedRequestAsync(RequestFinished finished, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.YieldOutputAsync(finished, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return state - 1;
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask StartAsync(List<UserRequest> requests, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return context.InvokeWithStateAsync<int>(CountFinishedRequestAndYieldResultAsync, StateKey, cancellationToken: cancellationToken);
|
||||
|
||||
async ValueTask<int> CountFinishedRequestAndYieldResultAsync(int state, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (UserRequest req in requests)
|
||||
{
|
||||
await context.SendMessageAsync(req, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return state + requests.Count;
|
||||
}
|
||||
}
|
||||
|
||||
internal async ValueTask RunWorkflowHandleEventsAsync<TInput>(Workflow workflow, TInput input) where TInput : notnull
|
||||
{
|
||||
StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case ExecutorInvokedEvent invoked:
|
||||
Console.WriteLine($"Executor invoked: {invoked.ExecutorId}");
|
||||
break;
|
||||
case ExecutorCompletedEvent completed:
|
||||
Console.WriteLine($"Executor completed: {completed.ExecutorId}");
|
||||
break;
|
||||
|
||||
// Other event types can be handled here as needed
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step10EntryPoint
|
||||
{
|
||||
public static Workflow CreateWorkflow()
|
||||
{
|
||||
TestEchoAgent echoAgent = new("echo", "Echo");
|
||||
return AgentWorkflowBuilder.BuildSequential(echoAgent);
|
||||
}
|
||||
public static Workflow WorkflowInstance => CreateWorkflow();
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
|
||||
{
|
||||
AIAgent hostAgent = WorkflowInstance.AsAIAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
|
||||
|
||||
AgentSession session = await hostAgent.CreateSessionAsync();
|
||||
foreach (string input in inputs)
|
||||
{
|
||||
AgentResponse response;
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
do
|
||||
{
|
||||
response = await hostAgent.RunAsync(input, session, new AgentRunOptions { ContinuationToken = continuationToken });
|
||||
} while ((continuationToken = response.ContinuationToken) is { });
|
||||
|
||||
foreach (ChatMessage message in response.Messages)
|
||||
{
|
||||
writer.WriteLine($"{message.AuthorName}: {message.Text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step11EntryPoint
|
||||
{
|
||||
public const int AgentCount = 2;
|
||||
|
||||
public const string EchoAgentIdPrefix = "echo-";
|
||||
public const string EchoAgentNamePrefix = "Echo";
|
||||
|
||||
public static string ExpectedOutputForInput(string input, int agentNumber)
|
||||
=> $"{EchoAgentNamePrefix}{agentNumber}: {input}";
|
||||
|
||||
public static Workflow CreateWorkflow()
|
||||
{
|
||||
TestEchoAgent[] echoAgents = Enumerable.Range(1, AgentCount)
|
||||
.Select(i => new TestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}"))
|
||||
.ToArray();
|
||||
|
||||
return AgentWorkflowBuilder.BuildConcurrent(echoAgents);
|
||||
}
|
||||
public static Workflow WorkflowInstance => CreateWorkflow();
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
|
||||
{
|
||||
AIAgent hostAgent = WorkflowInstance.AsAIAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
|
||||
|
||||
AgentSession session = await hostAgent.CreateSessionAsync();
|
||||
foreach (string input in inputs)
|
||||
{
|
||||
AgentResponse response;
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
do
|
||||
{
|
||||
response = await hostAgent.RunAsync(input, session, new AgentRunOptions { ContinuationToken = continuationToken });
|
||||
} while ((continuationToken = response.ContinuationToken) is { });
|
||||
|
||||
foreach (ChatMessage message in response.Messages)
|
||||
{
|
||||
writer.WriteLine($"{message.AuthorName}: {message.Text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal sealed class HandoffTestEchoAgent(string id, string name, string prefix = "")
|
||||
: TestEchoAgent(id, name, prefix)
|
||||
{
|
||||
protected override IEnumerable<ChatMessage> GetEpilogueMessages(AgentRunOptions? options = null)
|
||||
{
|
||||
if (options is ChatClientAgentRunOptions chatClientOptions &&
|
||||
chatClientOptions.ChatOptions != null)
|
||||
{
|
||||
IEnumerable<AITool>? handoffs = chatClientOptions.ChatOptions
|
||||
.Tools?
|
||||
.Where(tool => tool.Name?.StartsWith(HandoffWorkflowBuilder.FunctionPrefix,
|
||||
StringComparison.OrdinalIgnoreCase) is true);
|
||||
|
||||
if (handoffs != null)
|
||||
{
|
||||
AITool? handoff = handoffs.FirstOrDefault();
|
||||
if (handoff != null)
|
||||
{
|
||||
return [new(ChatRole.Assistant, [new FunctionCallContent(Guid.NewGuid().ToString("N"), handoff.Name)])
|
||||
{
|
||||
AuthorName = this.Name ?? this.Id,
|
||||
MessageId = Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base.GetEpilogueMessages(options);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Step12EntryPoint
|
||||
{
|
||||
public const int AgentCount = 2;
|
||||
|
||||
public const string EchoAgentIdPrefix = "echo-";
|
||||
public const string EchoAgentNamePrefix = "Echo";
|
||||
|
||||
public static string EchoPrefixForAgent(int agentNumber)
|
||||
=> $"{agentNumber}:";
|
||||
|
||||
public static Workflow CreateWorkflow()
|
||||
{
|
||||
TestEchoAgent[] echoAgents = Enumerable.Range(1, AgentCount)
|
||||
.Select(i => new HandoffTestEchoAgent($"{EchoAgentIdPrefix}{i}", $"{EchoAgentNamePrefix}{i}", EchoPrefixForAgent(i)))
|
||||
.ToArray();
|
||||
|
||||
return new HandoffWorkflowBuilder(echoAgents[0])
|
||||
.WithHandoff(echoAgents[0], echoAgents[1])
|
||||
.Build();
|
||||
}
|
||||
|
||||
public static Workflow WorkflowInstance => CreateWorkflow();
|
||||
|
||||
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment executionEnvironment, IEnumerable<string> inputs)
|
||||
{
|
||||
AIAgent hostAgent = WorkflowInstance.AsAIAgent("echo-workflow", "EchoW", executionEnvironment: executionEnvironment);
|
||||
|
||||
AgentSession session = await hostAgent.CreateSessionAsync();
|
||||
foreach (string input in inputs)
|
||||
{
|
||||
AgentResponse response;
|
||||
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
do
|
||||
{
|
||||
response = await hostAgent.RunAsync(input, session, new AgentRunOptions { ContinuationToken = continuationToken });
|
||||
} while ((continuationToken = response.ContinuationToken) is { });
|
||||
|
||||
foreach (ChatMessage message in response.Messages)
|
||||
{
|
||||
writer.WriteLine(message.Text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
internal static class Step13EntryPoint
|
||||
{
|
||||
public static Workflow SubworkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
OutputMessagesExecutor output = new(new ChatProtocolExecutorOptions() { StringMessageChatRole = ChatRole.User });
|
||||
return new WorkflowBuilder(output).WithOutputFrom(output).Build();
|
||||
}
|
||||
}
|
||||
|
||||
public static Workflow WorkflowInstance
|
||||
{
|
||||
get
|
||||
{
|
||||
ExecutorBinding subworkflow = SubworkflowInstance.BindAsExecutor("EchoSubworkflow");
|
||||
return new WorkflowBuilder(subworkflow).WithOutputFrom(subworkflow).Build();
|
||||
}
|
||||
}
|
||||
|
||||
public static async ValueTask<AgentSession> RunAsAgentAsync(TextWriter writer, string input, IWorkflowExecutionEnvironment environment, AgentSession? session)
|
||||
{
|
||||
AIAgent hostAgent = WorkflowInstance.AsAIAgent("echo-workflow", "EchoW", executionEnvironment: environment, includeWorkflowOutputsInResponse: true);
|
||||
|
||||
session ??= await hostAgent.CreateSessionAsync();
|
||||
AgentResponse response;
|
||||
ResponseContinuationToken? continuationToken = null;
|
||||
do
|
||||
{
|
||||
response = await hostAgent.RunAsync(input, session, new AgentRunOptions { ContinuationToken = continuationToken });
|
||||
} while ((continuationToken = response.ContinuationToken) is { });
|
||||
|
||||
foreach (ChatMessage message in response.Messages)
|
||||
{
|
||||
writer.WriteLine($"{message.AuthorName}: {message.Text}");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
public static async ValueTask<CheckpointInfo> RunAsync(TextWriter writer, string input, IWorkflowExecutionEnvironment environment, CheckpointInfo? resumeFrom)
|
||||
{
|
||||
await using StreamingRun run = await BeginAsync();
|
||||
|
||||
await run.TrySendMessageAsync(new TurnToken());
|
||||
|
||||
CheckpointInfo? lastCheckpoint = null;
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is WorkflowOutputEvent output)
|
||||
{
|
||||
if (output.Data is List<ChatMessage> messages)
|
||||
{
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
writer.WriteLine($"{output.ExecutorId}: {message.Text}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Fail($"Unexpected output type: {(output.Data == null ? "null" : output.Data?.GetType().Name)}");
|
||||
}
|
||||
}
|
||||
else if (evt is SuperStepCompletedEvent stepCompleted)
|
||||
{
|
||||
lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
}
|
||||
}
|
||||
|
||||
return lastCheckpoint!;
|
||||
|
||||
async ValueTask<StreamingRun> BeginAsync()
|
||||
{
|
||||
if (resumeFrom == null)
|
||||
{
|
||||
return await environment.RunStreamingAsync(WorkflowInstance, input);
|
||||
}
|
||||
|
||||
StreamingRun run = await environment.ResumeStreamingAsync(WorkflowInstance, resumeFrom);
|
||||
await run.TrySendMessageAsync(input);
|
||||
return run;
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Sample;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for shared state preservation across subworkflow boundaries.
|
||||
/// Validates fix for issue #2419: ".NET: Shared State is not preserved in Subworkflows"
|
||||
/// </summary>
|
||||
internal static partial class Step14EntryPoint
|
||||
{
|
||||
public const string WordStateScope = "WordStateScope";
|
||||
|
||||
/// <summary>
|
||||
/// Tests that shared state works WITHIN a subworkflow (internal persistence).
|
||||
/// This tests whether state written by one executor in a subworkflow can be
|
||||
/// read by another executor in the SAME subworkflow.
|
||||
/// </summary>
|
||||
public static async ValueTask<int> RunSubworkflowInternalStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
// All three executors are INSIDE the subworkflow
|
||||
TextReadExecutor textRead = new();
|
||||
TextTrimExecutor textTrim = new();
|
||||
CharCountingExecutor charCount = new();
|
||||
|
||||
Workflow subWorkflow = new WorkflowBuilder(textRead)
|
||||
.AddEdge(textRead, textTrim)
|
||||
.AddEdge(textTrim, charCount)
|
||||
.WithOutputFrom(charCount)
|
||||
.Build();
|
||||
|
||||
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("internalStateSubworkflow");
|
||||
|
||||
// Parent workflow just wraps the subworkflow
|
||||
Workflow workflow = new WorkflowBuilder(subWorkflowStep)
|
||||
.WithOutputFrom(subWorkflowStep)
|
||||
.Build();
|
||||
|
||||
await using Run run = await environment.RunAsync(workflow, text);
|
||||
|
||||
int? result = null;
|
||||
foreach (WorkflowEvent evt in run.OutgoingEvents)
|
||||
{
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
result = outputEvent.As<int>();
|
||||
writer.WriteLine($"Subworkflow internal state result: {result}");
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent failedEvent)
|
||||
{
|
||||
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
|
||||
throw failedEvent.Data as Exception ?? new InvalidOperationException(failedEvent.Data?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
return result ?? throw new InvalidOperationException("No output produced");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests cross-boundary state behavior (parent → subworkflow → parent).
|
||||
/// This documents the current behavior for issue #2419: state is isolated across subworkflow boundaries.
|
||||
/// </summary>
|
||||
public static async ValueTask<Exception?> RunCrossBoundaryStateAsync(string text, TextWriter writer, IWorkflowExecutionEnvironment environment)
|
||||
{
|
||||
TextReadExecutor textRead = new();
|
||||
TextTrimExecutor textTrim = new();
|
||||
CharCountingExecutor charCount = new();
|
||||
|
||||
// Create a subworkflow containing just the trim executor
|
||||
Workflow subWorkflow = new WorkflowBuilder(textTrim)
|
||||
.WithOutputFrom(textTrim)
|
||||
.Build();
|
||||
|
||||
ExecutorBinding subWorkflowStep = subWorkflow.BindAsExecutor("textTrimSubworkflow");
|
||||
|
||||
// Create the main workflow: parent → subworkflow → parent
|
||||
Workflow workflow = new WorkflowBuilder(textRead)
|
||||
.AddEdge(textRead, subWorkflowStep)
|
||||
.AddEdge(subWorkflowStep, charCount)
|
||||
.WithOutputFrom(charCount)
|
||||
.Build();
|
||||
|
||||
await using Run run = await environment.RunAsync(workflow, text);
|
||||
|
||||
foreach (WorkflowEvent evt in run.OutgoingEvents)
|
||||
{
|
||||
if (evt is WorkflowOutputEvent outputEvent)
|
||||
{
|
||||
writer.WriteLine($"Cross-boundary state result: {outputEvent.As<int>()}");
|
||||
return null; // Success - no error
|
||||
}
|
||||
else if (evt is WorkflowErrorEvent failedEvent)
|
||||
{
|
||||
writer.WriteLine($"Workflow failed: {failedEvent.Data}");
|
||||
return failedEvent.Data as Exception;
|
||||
}
|
||||
}
|
||||
|
||||
return new InvalidOperationException("No output produced");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that reads text and stores it in shared state with a generated key.
|
||||
/// </summary>
|
||||
internal sealed partial class TextReadExecutor() : Executor("TextReadExecutor")
|
||||
{
|
||||
[MessageHandler]
|
||||
public async ValueTask<string> HandleAsync(string text, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string key = Guid.NewGuid().ToString();
|
||||
await context.QueueStateUpdateAsync(key, text, scopeName: WordStateScope, cancellationToken);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that reads text from shared state, trims it, and updates the state.
|
||||
/// </summary>
|
||||
internal sealed partial class TextTrimExecutor() : Executor("TextTrimExecutor")
|
||||
{
|
||||
[MessageHandler]
|
||||
public async ValueTask<string> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
|
||||
if (content is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Word state not found for key: {key}");
|
||||
}
|
||||
|
||||
string trimmed = content.Trim();
|
||||
await context.QueueStateUpdateAsync(key, trimmed, scopeName: WordStateScope, cancellationToken);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executor that reads text from shared state and returns its character count.
|
||||
/// </summary>
|
||||
internal sealed partial class CharCountingExecutor() : Executor("CharCountingExecutor")
|
||||
{
|
||||
[MessageHandler]
|
||||
public async ValueTask<int> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string? content = await context.ReadStateAsync<string>(key, scopeName: WordStateScope, cancellationToken);
|
||||
return content?.Length ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user