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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -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;
}
}
@@ -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}");
}
}
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
};
}
}
@@ -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();
@@ -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;
}
}
}
}
@@ -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;
}
}
}
}
@@ -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}");
}
}
}
}
@@ -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);
}
}
}
}
@@ -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;
}
}
}
@@ -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;
}
}
}