.NET: [BREAKING] Implement Polymorphic Routing (#3792)

* feat: Implement Polymorphic Routing

* feat: Add support for Send/Yield annotations with basic Executor

* Adds annotations to Declarative workflow executors

* fix: Address PR Comments

* Implicit filter in collection loops
* Remove debug / usused / superfluous code
* Fix ProtocolBuilder implicit output registrations
* Fix logic error in ExecuteRouteGeneratorTests.ClassWithManualConfigureProtocol_DoesNotGenerate

* fix: Solidify type checks and send/yield type registrations

* fix: Suppress generation of TurnTokens out of AggregateTurnMessagesExecutor

* Fixes an issue where ConcurrentEndExecutor is not expecting TurnTokens.

* fix: Add ProtocolBuilder support for chained-delegation

* Updates Declarative pacakge to rely on chained-delegation Send/Yield registration
* Renames DeclarativeActionExectuor's new ExecuteAsync to ExecuteActionAsync to avoid colliding with Executor.ExecutoeAsync

* fix: Address PR Comments

* Fixes type mapping in FanInEdgeRunner
* Fixes and expalins send/yield type registration in FunctionExecutor

* fixup: build-break

* fix: Add missing SendsMesage declaration to InvokeAzureAgentExecutor
This commit is contained in:
Jacob Alber
2026-02-19 09:09:03 -05:00
committed by GitHub
Unverified
parent b05fc9e849
commit 6a3d22598f
82 changed files with 1585 additions and 823 deletions
@@ -16,6 +16,9 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -109,7 +109,7 @@ internal sealed class SloganGeneratedEvent(SloganResult sloganResult) : Workflow
/// 1. HandleAsync(string message): Handles the initial task to create a slogan.
/// 2. HandleAsync(Feedback message): Handles feedback to improve the slogan.
/// </summary>
internal sealed class SloganWriterExecutor : Executor
internal sealed partial class SloganWriterExecutor : Executor
{
private readonly AIAgent _agent;
private AgentSession? _session;
@@ -133,10 +133,7 @@ internal sealed class SloganWriterExecutor : Executor
this._agent = new ChatClientAgent(chatClient, agentOptions);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<string, SloganResult>(this.HandleAsync)
.AddHandler<FeedbackResult, SloganResult>(this.HandleAsync);
[MessageHandler]
public async ValueTask<SloganResult> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this._session ??= await this._agent.CreateSessionAsync(cancellationToken);
@@ -149,6 +146,7 @@ internal sealed class SloganWriterExecutor : Executor
return sloganResult;
}
[MessageHandler]
public async ValueTask<SloganResult> HandleAsync(FeedbackResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
var feedbackMessage = $"""
@@ -23,6 +23,9 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -6,7 +6,7 @@ using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentObservabilitySample;
internal static class WorkflowHelper
internal static partial class WorkflowHelper
{
/// <summary>
/// Creates a workflow that uses two language agents to process input concurrently.
@@ -50,21 +50,16 @@ internal static class WorkflowHelper
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder
.AddHandler<List<ChatMessage>>(this.RouteMessages)
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
}
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
[MessageHandler]
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
[MessageHandler]
internal ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
@@ -73,7 +68,8 @@ internal static class WorkflowHelper
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
[YieldsOutput(typeof(List<ChatMessage>))]
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
{
private readonly List<ChatMessage> _messages = [];
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -11,6 +11,10 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -196,7 +196,7 @@ internal sealed class CriticDecision
/// Executor that creates or revises content based on user requests or critic feedback.
/// This executor demonstrates multiple message handlers for different input types.
/// </summary>
internal sealed class WriterExecutor : Executor
internal sealed partial class WriterExecutor : Executor
{
private readonly AIAgent _agent;
@@ -213,15 +213,11 @@ internal sealed class WriterExecutor : Executor
);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string, ChatMessage>(this.HandleInitialRequestAsync)
.AddHandler<CriticDecision, ChatMessage>(this.HandleRevisionRequestAsync);
/// <summary>
/// Handles the initial writing request from the user.
/// </summary>
private async ValueTask<ChatMessage> HandleInitialRequestAsync(
[MessageHandler]
public async ValueTask<ChatMessage> HandleInitialRequestAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
@@ -232,7 +228,8 @@ internal sealed class WriterExecutor : Executor
/// <summary>
/// Handles revision requests from the critic with feedback.
/// </summary>
private async ValueTask<ChatMessage> HandleRevisionRequestAsync(
[MessageHandler]
public async ValueTask<ChatMessage> HandleRevisionRequestAsync(
CriticDecision decision,
IWorkflowContext context,
CancellationToken cancellationToken = default)
@@ -39,6 +39,14 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
this.Model = model;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return base.ConfigureProtocol(protocolBuilder)
// We chain to HandleAsync, so let the protocol know we have additional Send/Yield types that may not be
// available on the HandleAsync override.
.AddDelegateAttributeTypes(this.ExecuteAsync);
}
public DialogAction Model { get; }
public string ParentId { get => field ??= this.Model.GetParentId() ?? WorkflowActionVisitor.Steps.Root(); }
@@ -60,6 +68,7 @@ internal abstract class DeclarativeActionExecutor : Executor<ActionExecutorResul
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(ActionExecutorResult message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this.Model.Disabled)
@@ -4,6 +4,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Extensions.AI;
@@ -25,6 +26,7 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
return default;
}
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// No state to restore if we're starting from the beginning.
@@ -34,12 +34,28 @@ internal class DelegateActionExecutor<TMessage> : Executor<TMessage>, IResettabl
this._emitResult = emitResult;
}
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
ProtocolBuilder baseBuilder = base.ConfigureProtocol(protocolBuilder);
if (this._emitResult)
{
baseBuilder.SendsMessage<TMessage>();
}
// We chain to the provided delegate, so let the protocol know we have additional Send/Yield types that may not be
// available on the HandleAsync override.
return (this._action != null) ? baseBuilder.AddDelegateAttributeTypes(this._action)
: baseBuilder;
}
/// <inheritdoc/>
public ValueTask ResetAsync()
{
return default;
}
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (this._action is not null)
@@ -73,6 +73,7 @@ public abstract class ActionExecutor<TMessage> : Executor<TMessage>, IResettable
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
object? result = await this.ExecuteAsync(new DeclarativeWorkflowContext(context, this._session.State), message, cancellationToken).ConfigureAwait(false);
@@ -54,6 +54,7 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
@@ -17,6 +17,7 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeAzureAgent>(model, state)
{
@@ -49,6 +49,7 @@ internal sealed class InvokeFunctionToolExecutor(
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
[SendsMessage(typeof(ExternalInputRequest))]
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string functionName = this.GetFunctionName();
@@ -16,6 +16,8 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
[SendsMessage(typeof(ExternalInputResponse))]
internal sealed class QuestionExecutor(Question model, ResponseAgentProvider agentProvider, WorkflowFormulaState state) :
DeclarativeActionExecutor<Question>(model, state)
{
@@ -12,6 +12,8 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
[SendsMessage(typeof(ExternalInputRequest))]
[SendsMessage(typeof(ExternalInputResponse))]
internal sealed class RequestExternalInputExecutor(RequestExternalInput model, ResponseAgentProvider agentProvider, WorkflowFormulaState state)
: DeclarativeActionExecutor<RequestExternalInput>(model, state)
{
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
bool configureProtocol = HasConfigureProtocolDefined(classSymbol);
// Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
@@ -78,7 +78,7 @@ internal static class SemanticAnalyzer
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
bool baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol);
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
ImmutableEquatableArray<string> classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
ImmutableEquatableArray<string> classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName);
@@ -96,8 +96,8 @@ internal static class SemanticAnalyzer
return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureRoutes, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, hasManualConfigureRoutes,
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, configureProtocol,
classLocation,
handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
@@ -152,7 +152,7 @@ internal static class SemanticAnalyzer
if (first.HasManualConfigureRoutes)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureRoutesAlreadyDefined,
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
@@ -175,7 +175,7 @@ internal static class SemanticAnalyzer
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
first.BaseHasConfigureRoutes,
first.BaseHasConfigureProtocol,
new ImmutableEquatableArray<HandlerInfo>(handlers),
first.ClassSendTypes,
first.ClassYieldTypes);
@@ -211,7 +211,7 @@ internal static class SemanticAnalyzer
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
@@ -240,7 +240,7 @@ internal static class SemanticAnalyzer
containingTypeChain,
isPartialClass,
derivesFromExecutor,
hasManualConfigureRoutes,
hasManualConfigureProtocol,
classLocation,
typeName,
attributeKind));
@@ -251,12 +251,16 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have protocol attributes
/// (no [MessageHandler] methods). This generates only ConfigureSentTypes/ConfigureYieldTypes overrides.
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes
/// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol
/// configuration.
/// </summary>
/// <remarks>
/// This is likely to be seen combined with the basic one-method <c>Executor%lt;TIn&gt;</c> or <c>Executor&lt;TIn, TOut&gt;</c>
/// </remarks>
/// <param name="protocolInfos">The protocol info entries for the class.</param>
/// <returns>The combined analysis result.</returns>
public static AnalysisResult CombineProtocolOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
public static AnalysisResult CombineOutputOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
{
List<ClassProtocolInfo> protocols = protocolInfos.ToList();
if (protocols.Count == 0)
@@ -317,7 +321,7 @@ internal static class SemanticAnalyzer
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
BaseHasConfigureRoutes: false, // Not relevant for protocol-only
BaseHasConfigureProtocol: false, // Not relevant for protocol-only
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
@@ -394,12 +398,12 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Checks if this class directly defines ConfigureRoutes (not inherited).
/// Checks if this class directly defines ConfigureProtocol (not inherited).
/// If so, we skip generation to avoid conflicting with user's manual implementation.
/// </summary>
private static bool HasConfigureRoutesDefined(INamedTypeSymbol classSymbol)
private static bool HasConfigureProtocolDefined(INamedTypeSymbol classSymbol)
{
foreach (var member in classSymbol.GetMembers("ConfigureRoutes"))
foreach (var member in classSymbol.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract &&
SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol))
@@ -412,22 +416,22 @@ internal static class SemanticAnalyzer
}
/// <summary>
/// Checks if any base class (between this class and Executor) defines ConfigureRoutes.
/// If so, generated code should call base.ConfigureRoutes() to preserve inherited handlers.
/// Checks if any base class (between this class and Executor) defines ConfigureProtocol.
/// If so, generated code should call base.ConfigureProtocol() to preserve inherited handlers.
/// </summary>
private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol)
private static bool BaseHasConfigureProtocol(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? baseType = classSymbol.BaseType;
while (baseType != null)
{
string fullName = baseType.OriginalDefinition.ToDisplayString();
// Stop at Executor - its ConfigureRoutes is abstract/empty
// Stop at Executor - its ConfigureProtocol is abstract/empty
if (fullName == ExecutorTypeName)
{
return false;
}
foreach (var member in baseType.GetMembers("ConfigureRoutes"))
foreach (var member in baseType.GetMembers("ConfigureProtocol"))
{
if (member is IMethodSymbol method && !method.IsAbstract)
{
@@ -86,10 +86,10 @@ internal static class DiagnosticDescriptors
/// <summary>
/// MAFGENWF006: ConfigureRoutes already defined.
/// </summary>
public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new(
public static readonly DiagnosticDescriptor ConfigureProtocolAlreadyDefined = Register(new(
id: "MAFGENWF006",
title: "ConfigureRoutes already defined",
messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored",
title: "ConfigureProtocol already defined",
messageFormat: "Class '{0}' already defines ConfigureProtocol; [MessageHandler] methods will be ignored",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true));
@@ -120,7 +120,7 @@ public sealed class ExecutorRouteGenerator : IIncrementalGenerator
{
if (!processedClasses.Contains(kvp.Key))
{
yield return SemanticAnalyzer.CombineProtocolOnlyResults(kvp.Value);
yield return SemanticAnalyzer.CombineOutputOnlyResults(kvp.Value);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Agents.AI.Workflows.Generators.Models;
@@ -16,6 +17,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Generation;
/// </remarks>
internal static class SourceBuilder
{
internal const string IndentUnit = " ";
/// <summary>
/// Generates the complete source file for an executor's generated partial class.
/// </summary>
@@ -53,7 +56,8 @@ internal static class SourceBuilder
{
sb.AppendLine($"{indent}partial class {containingType}");
sb.AppendLine($"{indent}{{");
indent += " ";
indent += IndentUnit;
}
}
@@ -61,30 +65,49 @@ internal static class SourceBuilder
sb.AppendLine($"{indent}partial class {info.ClassName}{info.GenericParameters}");
sb.AppendLine($"{indent}{{");
string memberIndent = indent + " ";
bool hasContent = false;
string memberIndent = indent + IndentUnit;
// Only generate ConfigureRoutes if there are handlers
if (info.Handlers.Count > 0)
// ConfigureProtocol
sb.AppendLine($"{memberIndent}protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)");
sb.AppendLine($"{memberIndent}{{");
string bodyIndent = memberIndent + IndentUnit;
if (info.BaseHasConfigureProtocol)
{
GenerateConfigureRoutes(sb, info, memberIndent);
hasContent = true;
sb.Append($"{bodyIndent}return base.ConfigureProtocol(protocolBuilder)");
bodyIndent += " ";
}
else
{
sb.Append($"{bodyIndent}return protocolBuilder");
}
// Only generate protocol overrides if [SendsMessage] or [YieldsOutput] attributes are present.
// Without these attributes, we rely on the base class defaults.
if (info.ShouldGenerateProtocolOverrides)
if (info.ShouldGenerateSentMessageRegistrations)
{
if (hasContent)
{
sb.AppendLine();
}
GenerateConfigureSentTypes(sb, info, memberIndent);
sb.AppendLine();
GenerateConfigureYieldTypes(sb, info, memberIndent);
GenerateConfigureSentTypes(sb, info, bodyIndent);
}
if (info.ShouldGenerateYieldedOutputRegistrations)
{
GenerateConfigureYieldTypes(sb, info, bodyIndent);
}
// Only generate ConfigureRoutes if there are handlers
if (info.Handlers.Count > 0)
{
GenerateConfigureRoutes(sb, info, bodyIndent);
}
else
{
sb.AppendLine(";");
}
// Close ConfigureProtocol
sb.AppendLine($"{memberIndent}}}");
// Close class
sb.AppendLine($"{indent}}}");
@@ -107,24 +130,19 @@ internal static class SourceBuilder
/// </summary>
private static void GenerateConfigureRoutes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)");
sb.AppendLine(".ConfigureRoutes(ConfigureRoutes);");
sb.AppendLine($"{indent}void ConfigureRoutes(RouteBuilder routeBuilder)");
sb.AppendLine($"{indent}{{");
string bodyIndent = indent + " ";
// If a base class has its own ConfigureRoutes, chain to it first to preserve inherited handlers.
if (info.BaseHasConfigureRoutes)
{
sb.AppendLine($"{bodyIndent}routeBuilder = base.ConfigureRoutes(routeBuilder);");
sb.AppendLine();
}
string bodyIndent = indent + IndentUnit;
// Generate handler registrations using fluent AddHandler calls.
// RouteBuilder.AddHandler<TIn> registers a void handler; AddHandler<TIn, TOut> registers one with a return value.
if (info.Handlers.Count == 1)
{
HandlerInfo handler = info.Handlers[0];
sb.AppendLine($"{bodyIndent}return routeBuilder");
sb.AppendLine($"{bodyIndent}routeBuilder");
sb.Append($"{bodyIndent} .AddHandler");
AppendHandlerGenericArgs(sb, handler);
sb.AppendLine($"(this.{handler.MethodName});");
@@ -132,7 +150,7 @@ internal static class SourceBuilder
else
{
// Multiple handlers: chain fluent calls, semicolon only on the last one.
sb.AppendLine($"{bodyIndent}return routeBuilder");
sb.AppendLine($"{bodyIndent}routeBuilder");
for (int i = 0; i < info.Handlers.Count; i++)
{
@@ -178,28 +196,24 @@ internal static class SourceBuilder
/// </remarks>
private static void GenerateConfigureSentTypes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override ISet<Type> ConfigureSentTypes()");
sb.AppendLine($"{indent}{{");
// Track types to avoid emitting duplicate Add calls (the set handles runtime dedup,
// but cleaner generated code is easier to read).
var addedTypes = new HashSet<string>();
string bodyIndent = indent + " ";
sb.AppendLine($"{bodyIndent}var types = base.ConfigureSentTypes();");
foreach (var type in info.ClassSendTypes)
foreach (var type in info.ClassSendTypes.Where(type => addedTypes.Add(type)))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
sb.AppendLine($".SendsMessage<{type}>()");
sb.Append(indent);
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.SendTypes)
foreach (var type in handler.SendTypes.Where(type => addedTypes.Add(type)))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
sb.AppendLine($".SendsMessage<{type}>()");
sb.Append(indent);
}
}
sb.AppendLine($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
/// <summary>
@@ -211,43 +225,23 @@ internal static class SourceBuilder
/// </remarks>
private static void GenerateConfigureYieldTypes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override ISet<Type> ConfigureYieldTypes()");
sb.AppendLine($"{indent}{{");
string bodyIndent = indent + " ";
sb.AppendLine($"{bodyIndent}var types = base.ConfigureYieldTypes();");
// Track types to avoid emitting duplicate Add calls (the set handles runtime dedup,
// but cleaner generated code is easier to read).
var addedTypes = new HashSet<string>();
foreach (var type in info.ClassYieldTypes)
foreach (var type in info.ClassYieldTypes.Where(type => addedTypes.Add(type)))
{
if (addedTypes.Add(type))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
sb.AppendLine($".YieldsOutput<{type}>()");
sb.Append(indent);
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.YieldTypes)
foreach (var type in handler.YieldTypes.Where(type => addedTypes.Add(type)))
{
if (addedTypes.Add(type))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
}
// Handler return types (ValueTask<T>) are implicitly yielded.
if (handler.HasOutput && handler.OutputTypeName != null && addedTypes.Add(handler.OutputTypeName))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({handler.OutputTypeName}));");
sb.AppendLine($".YieldsOutput<{type}>()");
sb.Append(indent);
}
}
sb.AppendLine($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
}
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <param name="GenericParameters">The generic type parameters of the class (e.g., "&lt;T, U&gt;"), or null if not generic.</param>
/// <param name="IsNested">Whether the class is nested inside another class.</param>
/// <param name="ContainingTypeChain">The chain of containing types for nested classes (e.g., "OuterClass.InnerClass"). Empty string if not nested.</param>
/// <param name="BaseHasConfigureRoutes">Whether the base class has a ConfigureRoutes method that should be called.</param>
/// <param name="BaseHasConfigureProtocol">Whether the base class has a ConfigureRoutes method that should be called.</param>
/// <param name="Handlers">The list of handler methods to register.</param>
/// <param name="ClassSendTypes">The types declared via class-level [SendsMessage] attributes.</param>
/// <param name="ClassYieldTypes">The types declared via class-level [YieldsOutput] attributes.</param>
@@ -21,19 +21,20 @@ internal sealed record ExecutorInfo(
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
bool BaseHasConfigureProtocol,
ImmutableEquatableArray<HandlerInfo> Handlers,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes)
{
/// <summary>
/// Gets whether any protocol type overrides should be generated.
/// Gets whether any "Sent" message type registrations should be generated.
/// </summary>
public bool ShouldGenerateProtocolOverrides =>
!this.ClassSendTypes.IsEmpty ||
!this.ClassYieldTypes.IsEmpty ||
this.HasHandlerWithSendTypes ||
this.HasHandlerWithYieldTypes;
public bool ShouldGenerateSentMessageRegistrations => !this.ClassSendTypes.IsEmpty || this.HasHandlerWithSendTypes;
/// <summary>
/// Gets whether any "Yielded" output type registrations should be generated.
/// </summary>
public bool ShouldGenerateYieldedOutputRegistrations => !this.ClassYieldTypes.IsEmpty || this.HasHandlerWithYieldTypes;
/// <summary>
/// Gets whether any handler has explicit Send types.
@@ -22,7 +22,7 @@ internal sealed record MethodAnalysisResult(
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
bool BaseHasConfigureProtocol,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes,
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class SendsMessageAttribute : Attribute
{
/// <summary>
@@ -29,7 +29,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
public sealed class YieldsOutputAttribute : Attribute
{
/// <summary>
@@ -34,19 +34,29 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti
private readonly ChatRole? _stringMessageChatRole = options?.StringMessageChatRole;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ChatMessage>()
.SendsMessage<List<ChatMessage>>()
.SendsMessage<ChatMessage[]>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message)));
}
routeBuilder.AddHandler<ChatMessage>(ForwardMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(ForwardMessagesAsync)
// remove this once we internalize the typecheck logic
.AddHandler<ChatMessage[]>(ForwardMessagesAsync)
//.AddHandler<List<ChatMessage>>(ForwardMessagesAsync)
.AddHandler<TurnToken>(ForwardTurnTokenAsync);
}
}
private static ValueTask ForwardMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
@@ -26,7 +26,7 @@ public static class ChatProtocolExtensions
/// langword="false"/>.</returns>
public static bool IsChatProtocol(this ProtocolDescriptor descriptor, bool allowCatchAll = false)
{
bool foundListChatMessageInput = false;
bool foundIEnumerableChatMessageInput = false;
bool foundTurnTokenInput = false;
if (allowCatchAll && descriptor.AcceptsAll)
@@ -40,9 +40,9 @@ public static class ChatProtocolExtensions
// output type.
foreach (Type inputType in descriptor.Accepts)
{
if (inputType == typeof(List<ChatMessage>))
if (inputType == typeof(IEnumerable<ChatMessage>))
{
foundListChatMessageInput = true;
foundIEnumerableChatMessageInput = true;
}
else if (inputType == typeof(TurnToken))
{
@@ -50,7 +50,7 @@ public static class ChatProtocolExtensions
}
}
return foundListChatMessageInput && foundTurnTokenInput;
return foundIEnumerableChatMessageInput && foundTurnTokenInput;
}
/// <summary>
@@ -67,19 +67,26 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<List<ChatMessage>>()
.SendsMessage<TurnToken>();
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this.SupportsStringMessage)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
}
routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
.AddHandler<IEnumerable<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<ChatMessage[]>(this.AddMessagesAsync)
//.AddHandler<List<ChatMessage>>(this.AddMessagesAsync)
.AddHandler<TurnToken>(this.TakeTurnAsync);
}
}
/// <summary>
@@ -25,6 +25,7 @@ internal sealed class PortableMessageEnvelope
{
this.MessageType = envelope.MessageType;
this.Message = new PortableValue(envelope.Message);
this.Source = envelope.Source;
this.TargetId = envelope.TargetId;
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -9,10 +10,7 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData edgeData) :
EdgeRunner<DirectEdgeData>(runContext, edgeData)
{
private async ValueTask<Executor> FindRouterAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, tracer)
.ConfigureAwait(false);
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
using var activity = this.StartActivity();
activity?
@@ -35,8 +33,11 @@ internal sealed class DirectEdgeRunner(IRunnerContext runContext, DirectEdgeData
return null;
}
Executor target = await this.FindRouterAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
Type? messageType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken)
.ConfigureAwait(false);
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer, cancellationToken).ConfigureAwait(false);
if (CanHandle(target, messageType))
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.Delivered);
return new DeliveryMapping(envelope, target);
@@ -4,6 +4,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -65,7 +66,7 @@ internal sealed class EdgeMap
this._stepTracer = stepTracer;
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForEdgeAsync(Edge edge, MessageEnvelope message, CancellationToken cancellationToken = default)
{
EdgeId id = edge.Data.Id;
if (!this._edgeRunners.TryGetValue(id, out EdgeRunner? edgeRunner))
@@ -73,25 +74,25 @@ internal sealed class EdgeMap
throw new InvalidOperationException($"Edge {edge} not found in the edge map.");
}
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
public bool TryRegisterPort(IRunnerContext runContext, string executorId, RequestPort port)
=> this._portEdgeRunners.TryAdd(port.Id, ResponseEdgeRunner.ForPort(runContext, executorId, port));
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message, CancellationToken cancellationToken = default)
{
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer, cancellationToken);
}
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response)
public ValueTask<DeliveryMapping?> PrepareDeliveryForResponseAsync(ExternalResponse response, CancellationToken cancellationToken = default)
{
if (!this._portEdgeRunners.TryGetValue(response.PortInfo.PortId, out ResponseEdgeRunner? portRunner))
{
throw new InvalidOperationException($"Port {response.PortInfo.PortId} not found in the edge map.");
}
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer);
return portRunner.ChaseEdgeAsync(new MessageEnvelope(response, ExecutorIdentity.None), this._stepTracer, cancellationToken);
}
internal async ValueTask<Dictionary<EdgeId, PortableValue>> ExportStateAsync()
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
@@ -14,8 +16,7 @@ internal interface IStatefulEdgeRunner
internal abstract class EdgeRunner
{
// TODO: Can this be sync?
protected internal abstract ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer);
protected internal abstract ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken = default);
}
internal abstract class EdgeRunner<TEdgeData>(
@@ -24,5 +25,46 @@ internal abstract class EdgeRunner<TEdgeData>(
protected IRunnerContext RunContext { get; } = Throw.IfNull(runContext);
protected TEdgeData EdgeData { get; } = Throw.IfNull(edgeData);
protected async ValueTask<ExecutorProtocol> FindSourceProtocolAsync(string sourceId, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
Executor sourceExecutor = await this.RunContext.EnsureExecutorAsync(Throw.IfNull(sourceId), stepTracer, cancellationToken)
.ConfigureAwait(false);
return sourceExecutor.Protocol;
}
protected async ValueTask<Type?> GetMessageRuntimeTypeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
// The only difficulty occurs when we have gone through a checkpoint cycle, because the messages turn into PortableValue objects.
if (envelope.Message is PortableValue portableValue)
{
if (envelope.SourceId == null)
{
return null;
}
ExecutorProtocol protocol = await this.FindSourceProtocolAsync(envelope.SourceId, stepTracer, cancellationToken).ConfigureAwait(false);
return protocol.SendTypeTranslator.MapTypeId(portableValue.TypeId);
}
return envelope.Message.GetType();
}
protected static bool CanHandle(Executor target, Type? runtimeType)
{
// If we have a runtimeType, this is either a non-serialized object, or we successfully mapped a PortableValue back to its original type.
// In either case, we can check if the target can handle that type. Alternatively, even if we do not have a type, if the target has a catch-all,
// we can still route to it, since it should be able to handle anything.
return runtimeType != null ? target.CanHandle(runtimeType) : target.Router.HasCatchAll;
}
protected async ValueTask<bool> CanHandleAsync(string candidateTargetId, Type? runtimeType, IStepTracer? stepTracer, CancellationToken cancellationToken = default)
{
Executor candidateTarget = await this.RunContext.EnsureExecutorAsync(Throw.IfNull(candidateTargetId), stepTracer, cancellationToken)
.ConfigureAwait(false);
return CanHandle(candidateTarget, runtimeType);
}
protected Activity? StartActivity() => this.RunContext.TelemetryContext.StartEdgeGroupProcessActivity();
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -15,7 +16,7 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
{
private FanInEdgeState _state = new(edgeData);
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
Debug.Assert(!envelope.IsExternal, "FanIn edges should never be chased from external input");
@@ -31,7 +32,7 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
}
// source.Id is guaranteed to be non-null here because source is not None.
IEnumerable<MessageEnvelope>? releasedMessages = this._state.ProcessMessage(envelope.SourceId, envelope);
List<IGrouping<ExecutorIdentity, MessageEnvelope>>? releasedMessages = this._state.ProcessMessage(envelope.SourceId, envelope)?.ToList();
if (releasedMessages is null)
{
// Not ready to process yet.
@@ -41,11 +42,22 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
try
{
// TODO: Filter messages based on accepted input types?
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer)
// Right now, for serialization purposes every message through FanInEdge goes through the PortableMessageEnvelope state, meaning
// we lose type information for all of them, potentially.
(ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>)[]
protocolGroupings = await Task.WhenAll(releasedMessages.Select(MapProtocolsAsync))
.ConfigureAwait(false);
IEnumerable<(Type? RuntimeType, MessageEnvelope MessageEnvelope)>
typedEnvelopes = protocolGroupings.SelectMany(MapRuntimeTypes);
Executor target = await this.RunContext.EnsureExecutorAsync(this.EdgeData.SinkId, stepTracer, cancellationToken)
.ConfigureAwait(false);
// Materialize the filtered list via ToList() to avoid multiple enumerations
var finalReleasedMessages = releasedMessages.Where(envelope => target.CanHandle(envelope.MessageType)).ToList();
List<MessageEnvelope> finalReleasedMessages = typedEnvelopes.Where(te => CanHandle(target, te.RuntimeType))
.Select(te => te.MessageEnvelope)
.ToList();
if (finalReleasedMessages.Count == 0)
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.DroppedTypeMismatch);
@@ -53,6 +65,28 @@ internal sealed class FanInEdgeRunner(IRunnerContext runContext, FanInEdgeData e
}
return new DeliveryMapping(finalReleasedMessages, target);
async Task<(ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>)> MapProtocolsAsync(IGrouping<ExecutorIdentity, MessageEnvelope> grouping)
{
ExecutorProtocol protocol = await this.FindSourceProtocolAsync(grouping.Key.Id!, stepTracer, cancellationToken).ConfigureAwait(false);
return (protocol, grouping);
}
IEnumerable<(Type?, MessageEnvelope)> MapRuntimeTypes((ExecutorProtocol, IGrouping<ExecutorIdentity, MessageEnvelope>) input)
{
(ExecutorProtocol protocol, IGrouping<ExecutorIdentity, MessageEnvelope> grouping) = input;
return grouping.Select(envelope => (ResolveEnvelopeType(envelope), envelope));
Type? ResolveEnvelopeType(MessageEnvelope messageEnvelope)
{
if (messageEnvelope.Message is PortableValue portableValue)
{
return protocol.SendTypeTranslator.MapTypeId(portableValue.TypeId);
}
return messageEnvelope.Message.GetType();
}
}
}
catch (Exception) when (activity is not null)
{
@@ -32,7 +32,7 @@ internal sealed class FanInEdgeState
this._pendingMessages = pendingMessages;
}
public IEnumerable<MessageEnvelope>? ProcessMessage(string sourceId, MessageEnvelope envelope)
public IEnumerable<IGrouping<ExecutorIdentity, MessageEnvelope>>? ProcessMessage(string sourceId, MessageEnvelope envelope)
{
this.PendingMessages.Add(new(envelope));
this.Unseen.Remove(sourceId);
@@ -47,7 +47,8 @@ internal sealed class FanInEdgeState
return null;
}
return takenMessages.Select(portable => portable.ToMessageEnvelope());
return takenMessages.Select(portable => portable.ToMessageEnvelope())
.GroupBy(keySelector: messageEnvelope => messageEnvelope.Source);
}
return null;
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
@@ -11,7 +12,7 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData edgeData) :
EdgeRunner<FanOutEdgeData>(runContext, edgeData)
{
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
using var activity = this.StartActivity();
activity?
@@ -39,7 +40,10 @@ internal sealed class FanOutEdgeRunner(IRunnerContext runContext, FanOutEdgeData
return null;
}
IEnumerable<Executor> validTargets = result.Where(t => t.CanHandle(envelope.MessageType));
Type? runtimeType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken)
.ConfigureAwait(false);
IEnumerable<Executor> validTargets = result.Where(t => CanHandle(t, runtimeType));
if (!validTargets.Any())
{
@@ -1,7 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
@@ -27,8 +29,24 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class MessageRouter
{
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers;
private readonly Dictionary<TypeId, Type> _runtimeTypeMap;
private readonly Type[] _interfaceHandlers;
//private readonly Dictionary<Type, MessageHandlerF> _typedHandlers;
//private readonly Dictionary<TypeId, Type> _runtimeTypeMap = new();
private readonly ConcurrentDictionary<TypeId, TypeHandlingInfo> _typeInfos = new();
private record TypeHandlingInfo(Type RuntimeType, MessageHandlerF Handler)
{
[Conditional("DEBUG")]
private void AssertTypeCovaraince(Type expectedDerviedType) => Debug.Assert(this.RuntimeType.IsAssignableFrom(expectedDerviedType));
public TypeHandlingInfo ForDerviedType(Type derivedType)
{
this.AssertTypeCovaraince(derivedType);
return this with { RuntimeType = derivedType };
}
}
private readonly CatchAllF? _catchAllFunc;
@@ -36,8 +54,18 @@ internal sealed class MessageRouter
{
Throw.IfNull(handlers);
this._typedHandlers = handlers;
this._runtimeTypeMap = handlers.Keys.ToDictionary(t => new TypeId(t), t => t);
HashSet<Type> interfaceHandlers = new();
foreach (Type type in handlers.Keys)
{
this._typeInfos[new(type)] = new(type, handlers[type]);
if (type.IsInterface)
{
interfaceHandlers.Add(type);
}
}
this._interfaceHandlers = interfaceHandlers.ToArray();
this._catchAllFunc = catchAllFunc;
this.IncomingTypes = [.. handlers.Keys];
@@ -49,16 +77,44 @@ internal sealed class MessageRouter
[MemberNotNullWhen(true, nameof(_catchAllFunc))]
internal bool HasCatchAll => this._catchAllFunc is not null;
public bool CanHandle(object message) => this.CanHandle(new TypeId(Throw.IfNull(message).GetType()));
public bool CanHandle(Type candidateType) => this.CanHandle(new TypeId(Throw.IfNull(candidateType)));
public bool CanHandle(TypeId candidateType)
{
return this.HasCatchAll || this._runtimeTypeMap.ContainsKey(candidateType);
}
public bool CanHandle(object message) => this.CanHandle(Throw.IfNull(message).GetType());
public bool CanHandle(Type candidateType) => this.HasCatchAll || this.FindHandler(candidateType) is not null;
public HashSet<Type> DefaultOutputTypes { get; }
private MessageHandlerF? FindHandler(Type messageType)
{
for (Type? candidateType = messageType; candidateType != null; candidateType = candidateType.BaseType)
{
TypeId candidateTypeId = new(candidateType);
if (this._typeInfos.TryGetValue(candidateTypeId, out TypeHandlingInfo? handlingInfo))
{
if (candidateType != messageType)
{
TypeHandlingInfo actualInfo = handlingInfo.ForDerviedType(messageType);
this._typeInfos.TryAdd(new(messageType), actualInfo);
}
return handlingInfo.Handler;
}
else if (this._interfaceHandlers.Length > 0)
{
foreach (Type interfaceType in this._interfaceHandlers.Where(it => it.IsAssignableFrom(candidateType)))
{
handlingInfo = this._typeInfos[new(interfaceType)];
// By definition we do not have a pre-calculated handler information for this candidateType, otherwise
// we would have found it above. This also means we do not have a corresponding entry for the messageType.
this._typeInfos.TryAdd(new(messageType), handlingInfo.ForDerviedType(messageType));
return handlingInfo.Handler;
}
}
}
return null;
}
public async ValueTask<CallResult?> RouteMessageAsync(object message, IWorkflowContext context, bool requireRoute = false, CancellationToken cancellationToken = default)
{
Throw.IfNull(message);
@@ -67,15 +123,16 @@ internal sealed class MessageRouter
PortableValue? portableValue = message as PortableValue;
if (portableValue != null &&
this._runtimeTypeMap.TryGetValue(portableValue.TypeId, out Type? runtimeType))
this._typeInfos.TryGetValue(portableValue.TypeId, out TypeHandlingInfo? handlingInfo))
{
// If we found a runtime type, we can use it
message = portableValue.AsType(runtimeType) ?? message;
message = portableValue.AsType(handlingInfo.RuntimeType) ?? message;
}
try
{
if (this._typedHandlers.TryGetValue(message.GetType(), out MessageHandlerF? handler))
MessageHandlerF? handler = this.FindHandler(message.GetType());
if (handler != null)
{
result = await handler(message, context, cancellationToken).ConfigureAwait(false);
}
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
@@ -21,7 +22,7 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
public string ExecutorId => executorId;
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer, CancellationToken cancellationToken)
{
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
@@ -34,7 +35,10 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
try
{
Executor target = await this.FindExecutorAsync(stepTracer).ConfigureAwait(false);
if (target.CanHandle(envelope.MessageType))
Type? runtimeType = await this.GetMessageRuntimeTypeAsync(envelope, stepTracer, cancellationToken).ConfigureAwait(false);
if (CanHandle(target, runtimeType))
{
activity?.SetEdgeRunnerDeliveryStatus(EdgeRunnerDeliveryStatus.Delivered);
return new DeliveryMapping(envelope, target);
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -15,6 +16,128 @@ using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class DelayedExternalRequestContext : IExternalRequestContext
{
public DelayedExternalRequestContext(IExternalRequestContext? targetContext = null)
{
this._targetContext = targetContext;
}
private sealed class DelayRegisteredSink : IExternalRequestSink
{
internal IExternalRequestSink? TargetSink { get; set; }
public ValueTask PostAsync(ExternalRequest request) =>
this.TargetSink is null
? throw new InvalidOperationException("The external request sink has not been registered yet.")
: this.TargetSink.PostAsync(request);
}
private readonly Dictionary<string, (RequestPort Port, DelayRegisteredSink Sink)> _requestPorts = [];
private IExternalRequestContext? _targetContext;
public void ApplyPortRegistrations(IExternalRequestContext targetContext)
{
this._targetContext = targetContext;
foreach ((RequestPort requestPort, DelayRegisteredSink? sink) in this._requestPorts.Values)
{
sink?.TargetSink = targetContext.RegisterPort(requestPort);
}
}
public IExternalRequestSink RegisterPort(RequestPort port)
{
DelayRegisteredSink delaySink = new()
{
TargetSink = this._targetContext?.RegisterPort(port),
};
this._requestPorts.Add(port.Id, (port, delaySink));
return delaySink;
}
}
internal sealed class MessageTypeTranslator
{
private readonly Dictionary<TypeId, Type> _typeLookupMap = [];
private readonly Dictionary<Type, TypeId> _declaredTypeMap = [];
// The types that can always be sent; this is a very inelegant solution to the following problem:
// Even with code analysis it is impossible to statically know all of the types that get sent via SendMessage, because
// IWorkflowContext can always be sent out of the current assembly (to say nothing of Reflection). This means at some
// level we have to register all the types being sent somewhere. Since we have to do dynamic serialization/deserialization
// at runtime with dependency-defined types (which we do not statically know) we need to have these types at runtime.
// At the same time, we should not force users to declare types to interact with core system concepts like RequestInfo.
// So the solution for now is to register a set of known types, at the cost of duplicating this per Executor.
//
// - TODO: Create a static translation map, and keep a set of "allowed" TypeIds per Excutor.
private static IEnumerable<Type> KnownSentTypes =>
[
typeof(ExternalRequest),
typeof(ExternalResponse),
// TurnToken?
];
public MessageTypeTranslator(ISet<Type> types)
{
foreach (Type type in KnownSentTypes.Concat(types))
{
TypeId typeId = new(type);
if (this._typeLookupMap.ContainsKey(typeId))
{
continue;
}
this._typeLookupMap[typeId] = type;
this._declaredTypeMap[type] = typeId;
}
}
public TypeId? GetDeclaredType(Type messageType)
{
// If the user declares a base type, the user is expected to set up any serialization to be able to deal with
// the polymorphism transparently to the framework, or be expecting to deal with the appropriate truncation.
for (Type? candidateType = messageType; candidateType != null; candidateType = candidateType.BaseType)
{
if (this._declaredTypeMap.TryGetValue(candidateType, out TypeId? declaredTypeId))
{
if (candidateType != messageType)
{
// Add an entry for the derived type to speed up future lookups.
this._declaredTypeMap[messageType] = declaredTypeId;
}
return declaredTypeId;
}
}
return null;
}
public Type? MapTypeId(TypeId candidateTypeId) =>
this._typeLookupMap.TryGetValue(candidateTypeId, out Type? mappedType)
? mappedType
: null;
}
internal sealed class ExecutorProtocol(MessageRouter router, ISet<Type> sendTypes, ISet<Type> yieldTypes)
{
private readonly HashSet<TypeId> _yieldTypes = new(yieldTypes.Select(type => new TypeId(type)));
public MessageTypeTranslator SendTypeTranslator => field ??= new MessageTypeTranslator(sendTypes);
internal MessageRouter Router => router;
public bool CanHandle(Type type) => router.CanHandle(type);
public bool CanOutput(Type type) => this._yieldTypes.Contains(new(type));
public ProtocolDescriptor Describe() => new(this.Router.IncomingTypes, yieldTypes, sendTypes, this.Router.HasCatchAll);
}
/// <summary>
/// A component that processes messages in a <see cref="Workflow"/>.
/// </summary>
@@ -50,6 +173,10 @@ public abstract class Executor : IIdentified
this.IsCrossRunShareable = declareCrossRunShareable;
}
private DelayedExternalRequestContext DelayedPortRegistrations { get; } = new();
internal ExecutorProtocol Protocol => field ??= this.ConfigureProtocol(new(this.DelayedPortRegistrations)).Build(this.Options);
internal bool IsCrossRunShareable { get; }
/// <summary>
@@ -57,28 +184,29 @@ public abstract class Executor : IIdentified
/// </summary>
protected ExecutorOptions Options { get; }
/// <summary>
/// Override this method to register handlers for the executor.
/// </summary>
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
//private bool _configuringProtocol;
internal void Configure(IExternalRequestContext externalRequestContext)
/// <summary>
/// Configures the protocol by setting up routes and declaring the message types used for sending and yielding
/// output.
/// </summary>
/// <remarks>This method serves as the primary entry point for protocol configuration. It integrates route
/// setup and message type declarations. For backward compatibility, it is currently invoked from the
/// RouteBuilder.</remarks>
/// <returns>An instance of <see cref="ExecutorProtocol"/> that represents the fully configured protocol.</returns>
protected abstract ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder);
internal void AttachRequestContext(IExternalRequestContext externalRequestContext)
{
// TODO: This is an unfortunate pattern (pending the ability to rework the Configure APIs a bit):
// new()
// >>> will throw InvalidOperationException if Configure() is not invoked when using PortHandlers
// .Configure()
// >>> will throw InvalidOperationException if AttachRequestContext() is not invoked when using PortHandlers
// .AttachRequestContext()
// >>> only usable now
// The fix would be to change the API surface of Executor to have Configure return the contract that the workflow
// will use to invoke the executor (currently the MessageRouter). (Ideally we would rename Executor to Node or similar,
// and the actual Executor class will represent that Contract object)
// Not a terrible issue right now because only InProcessExecution exists right now, and the InProccessRunContext centralizes
// executor instantiation in EnsureExecutorAsync.
this.Router = this.CreateRouter(externalRequestContext);
}
private MessageRouter CreateRouter(IExternalRequestContext? externalRequestContext = null)
=> this.ConfigureRoutes(new RouteBuilder(externalRequestContext)).Build();
this.DelayedPortRegistrations.ApplyPortRegistrations(externalRequestContext);
_ = this.Protocol; // Force protocol to be built if not already done.
}
/// <summary>
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
@@ -90,42 +218,7 @@ public abstract class Executor : IIdentified
protected internal virtual ValueTask InitializeAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
=> default;
/// <summary>
/// Override this method to declare the types of messages this executor can send.
/// </summary>
/// <returns></returns>
protected virtual ISet<Type> ConfigureSentTypes() => new HashSet<Type>([typeof(object)]);
/// <summary>
/// Override this method to declare the types of messages this executor can yield as workflow outputs.
/// </summary>
/// <returns></returns>
protected virtual ISet<Type> ConfigureYieldTypes()
{
if (this.Options.AutoYieldOutputHandlerResultObject)
{
return this.Router.DefaultOutputTypes;
}
return new HashSet<Type>();
}
internal MessageRouter Router
{
get
{
if (field is null)
{
field = this.CreateRouter();
}
return field;
}
private set
{
field = value;
}
}
internal MessageRouter Router => this.Protocol.Router;
/// <summary>
/// Process an incoming message using the registered handlers.
@@ -139,10 +232,10 @@ public abstract class Executor : IIdentified
/// <returns>A ValueTask representing the asynchronous operation, wrapping the output from the executor.</returns>
/// <exception cref="NotSupportedException">No handler found for the message type.</exception>
/// <exception cref="TargetInvocationException">An exception is generated while handling the message.</exception>
public ValueTask<object?> ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default)
=> this.ExecuteAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken);
public ValueTask<object?> ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, CancellationToken cancellationToken = default)
=> this.ExecuteCoreAsync(message, messageType, context, WorkflowTelemetryContext.Disabled, cancellationToken);
internal async ValueTask<object?> ExecuteAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default)
internal async ValueTask<object?> ExecuteCoreAsync(object message, TypeId messageType, IWorkflowContext context, WorkflowTelemetryContext telemetryContext, CancellationToken cancellationToken = default)
{
using var activity = telemetryContext.StartExecutorProcessActivity(this.Id, this.GetType().FullName, messageType.TypeName, message);
activity?.CreateSourceLinks(context.TraceContext);
@@ -224,41 +317,22 @@ public abstract class Executor : IIdentified
/// <summary>
/// A set of <see cref="Type"/>s, representing the messages this executor can produce as output.
/// </summary>
public ISet<Type> OutputTypes { get; } = new HashSet<Type>([typeof(object)]);
public ISet<Type> OutputTypes => field ??= new HashSet<Type>(this.Protocol.Describe().Yields);
/// <summary>
/// Describes the protocol for communication with this <see cref="Executor"/>.
/// </summary>
/// <returns></returns>
public ProtocolDescriptor DescribeProtocol()
{
// TODO: Once burden of annotating yield/output messages becomes easier for the non-Auto case,
// we should (1) start checking for validity on output/send side, and (2) add the Yield/Send
// types to the ProtocolDescriptor.
return new(this.InputTypes, this.Router.HasCatchAll);
}
public ProtocolDescriptor DescribeProtocol() => this.Protocol.Describe();
/// <summary>
/// Checks if the executor can handle a specific message type.
/// </summary>
/// <param name="messageType"></param>
/// <returns></returns>
public bool CanHandle(Type messageType) => this.Router.CanHandle(messageType);
public bool CanHandle(Type messageType) => this.Protocol.CanHandle(messageType);
internal bool CanHandle(TypeId messageType) => this.Router.CanHandle(messageType);
internal bool CanOutput(Type messageType)
{
foreach (Type type in this.OutputTypes)
{
if (type.IsAssignableFrom(messageType))
{
return true;
}
}
return false;
}
internal bool CanOutput(Type messageType) => this.Protocol.CanOutput(messageType);
}
/// <summary>
@@ -272,8 +346,14 @@ public abstract class Executor<TInput>(string id, ExecutorOptions? options = nul
: Executor(id, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType());
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -292,8 +372,14 @@ public abstract class Executor<TInput, TOutput>(string id, ExecutorOptions? opti
IMessageHandler<TInput, TOutput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType());
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -1,6 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -13,14 +16,28 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerAsync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync)
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask> WrapAction(Action<TInput, IWorkflowContext, CancellationToken> handlerSync, out IEnumerable<Type> sentTypes, out IEnumerable<Type> yieldedTypes)
{
if (handlerSync.Method != null)
{
MethodInfo method = handlerSync.Method;
(sentTypes, yieldedTypes) = method.GetAttributeTypes();
}
else
{
sentTypes = yieldedTypes = [];
}
return RunActionAsync;
ValueTask RunActionAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken)
@@ -30,6 +47,15 @@ public class FunctionExecutor<TInput>(string id,
}
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
// We have to register the delegate handlers here because the base class gets the RunActionAsync local function in
// WrapAction, which cannot have the right annotations.
.AddDelegateAttributeTypes(handlerAsync)
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
/// <inheritdoc/>
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken);
@@ -39,8 +65,15 @@ public class FunctionExecutor<TInput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
public FunctionExecutor(string id,
Action<TInput, IWorkflowContext, CancellationToken> handlerSync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable)
{
}
}
@@ -53,10 +86,14 @@ public class FunctionExecutor<TInput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Additional message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Additional message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public class FunctionExecutor<TInput, TOutput>(string id,
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerAsync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : Executor<TInput, TOutput>(id, options, declareCrossRunShareable)
{
internal static Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> WrapFunc(Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync)
@@ -70,6 +107,15 @@ public class FunctionExecutor<TInput, TOutput>(string id,
}
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
// We have to register the delegate handlers here because the base class gets the RunFuncAsync local function in
// WrapFunc, which cannot have the right annotations.
.AddDelegateAttributeTypes(handlerAsync)
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
/// <inheritdoc/>
public override ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken) => handlerAsync(message, context, cancellationToken);
@@ -79,8 +125,15 @@ public class FunctionExecutor<TInput, TOutput>(string id,
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Additional message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Additional message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
public FunctionExecutor(string id,
Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync,
ExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable)
{
}
}
@@ -201,14 +201,43 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
this.StepTracer.TraceActivated(receiverId);
while (envelopes.TryDequeue(out var envelope))
{
await executor.ExecuteAsync(
envelope.Message,
envelope.MessageType,
(object message, TypeId messageType) = await TranslateMessageAsync(envelope).ConfigureAwait(false);
await executor.ExecuteCoreAsync(
message,
messageType,
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
this.TelemetryContext,
cancellationToken
).ConfigureAwait(false);
}
async ValueTask<(object, TypeId)> TranslateMessageAsync(MessageEnvelope envelope)
{
object? value = envelope.Message;
TypeId messageType = envelope.MessageType;
if (!envelope.IsExternal)
{
Executor source = await this.RunContext.EnsureExecutorAsync(envelope.SourceId, this.StepTracer, cancellationToken).ConfigureAwait(false);
Type? actualType = source.Protocol.SendTypeTranslator.MapTypeId(envelope.MessageType);
if (actualType == null)
{
// In principle, this should never happen, since we always use the SendTypeTranslator to generate the outgoing TypeId in the first place.
throw new InvalidOperationException($"Cannot translate message type ID '{envelope.MessageType}' from executor '{source.Id}'.");
}
messageType = new(actualType);
if (value is PortableValue portableValue &&
!portableValue.IsType(actualType, out value))
{
throw new InvalidOperationException($"Cannot interpret incoming message of type '{portableValue.TypeId}' as type '{actualType.FullName}'.");
}
}
return (value, messageType);
}
}
private async ValueTask RunSuperstepAsync(StepContext currentStep, CancellationToken cancellationToken)
@@ -95,7 +95,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
executor.Configure(this.BindExternalRequestContext(executorId));
executor.AttachRequestContext(this.BindExternalRequestContext(executorId));
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
.ConfigureAwait(false);
@@ -182,7 +182,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
while (this._queuedExternalDeliveries.TryDequeue(out var deliveryPrep))
{
// It's important we do not try to run these in parallel, because they make be modifying
// It's important we do not try to run these in parallel, because they may be modifying
// inner edge state, etc.
await deliveryPrep().ConfigureAwait(false);
}
@@ -212,14 +212,23 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
this.CheckEnded();
MessageEnvelope envelope = new(message, sourceId, targetId: targetId, traceContext: traceContext);
Debug.Assert(this._executors.ContainsKey(sourceId));
Executor source = await this.EnsureExecutorAsync(sourceId, tracer: null, cancellationToken).ConfigureAwait(false);
TypeId? declaredType = source.Protocol.SendTypeTranslator.GetDeclaredType(message.GetType());
if (declaredType is null)
{
throw new InvalidOperationException($"Executor '{sourceId}' cannot send messages of type '{message.GetType().FullName}'.");
}
MessageEnvelope envelope = new(message, sourceId, declaredType, targetId: targetId, traceContext: traceContext);
if (this._workflow.Edges.TryGetValue(sourceId, out HashSet<Edge>? edges))
{
foreach (Edge edge in edges)
{
DeliveryMapping? maybeMapping =
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope)
await this._edgeMap.PrepareDeliveryForEdgeAsync(edge, envelope, cancellationToken)
.ConfigureAwait(false);
maybeMapping?.MapInto(this._nextStep);
@@ -310,12 +319,12 @@ internal sealed class InProcessRunnerContext : IRunnerContext
public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default)
{
return RunnerContext.SendMessageAsync(ExecutorId, message, targetId, cancellationToken);
return RunnerContext.SendMessageAsync(ExecutorId, Throw.IfNull(message), targetId, cancellationToken);
}
public ValueTask YieldOutputAsync(object output, CancellationToken cancellationToken = default)
{
return RunnerContext.YieldOutputAsync(ExecutorId, output, cancellationToken);
return RunnerContext.YieldOutputAsync(ExecutorId, Throw.IfNull(output), cancellationToken);
}
public ValueTask RequestHaltAsync() => this.AddEventAsync(new RequestHaltEvent());
@@ -0,0 +1,174 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
internal static class MemberAttributeExtensions
{
public static (IEnumerable<Type> Sent, IEnumerable<Type> Yielded) GetAttributeTypes(this MemberInfo memberInfo)
{
IEnumerable<SendsMessageAttribute> sendsMessageAttrs = memberInfo.GetCustomAttributes<SendsMessageAttribute>();
IEnumerable<YieldsOutputAttribute> yieldsOutputAttrs = memberInfo.GetCustomAttributes<YieldsOutputAttribute>();
// TODO: Should we include [MessageHandler]?
return (Sent: sendsMessageAttrs.Select(attr => attr.Type), Yielded: yieldsOutputAttrs.Select(attr => attr.Type));
}
}
/// <summary>
/// .
/// </summary>
public sealed class ProtocolBuilder
{
private readonly HashSet<Type> _sendTypes = [];
private readonly HashSet<Type> _yieldTypes = [];
internal ProtocolBuilder(DelayedExternalRequestContext delayRequestContext)
{
this.RouteBuilder = new RouteBuilder(delayRequestContext);
}
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="Delegate"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="delegate">The delegate to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddDelegateAttributeTypes(Delegate @delegate)
=> this.AddMethodAttributeTypes(Throw.IfNull(@delegate).Method);
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="MethodInfo"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="method">The method to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddMethodAttributeTypes(MethodInfo method)
{
(IEnumerable<Type> sentTypes, IEnumerable<Type> yieldTypes) = method.GetAttributeTypes();
this._sendTypes.UnionWith(sentTypes);
this._yieldTypes.UnionWith(yieldTypes);
return method.DeclaringType != null ? this.AddClassAttributeTypes(method.DeclaringType)
: this;
}
/// <summary>
/// Adds types registered in <see cref="SendsMessageAttribute"/> or <see cref="YieldsOutputAttribute"/>
/// on the target <see cref="Type"/>. This can be used to implement delegate-based request handling akin
/// to what is provided by <see cref="Executor{TInput}"/> or <see cref="Executor{TIn,TOut}"/>.
/// </summary>
/// <param name="executorType">The type to be registered.</param>
/// <returns></returns>
public ProtocolBuilder AddClassAttributeTypes(Type executorType)
{
(IEnumerable<Type> sentTypes, IEnumerable<Type> yieldTypes) = executorType.GetAttributeTypes();
this._sendTypes.UnionWith(sentTypes);
this._yieldTypes.UnionWith(yieldTypes);
return this;
}
/// <summary>
/// Adds the specified type to the set of declared "sent" message types for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <typeparam name="TMessage">The type to be declared.</typeparam>
/// <returns></returns>
public ProtocolBuilder SendsMessage<TMessage>() where TMessage : notnull => this.SendsMessageTypes([typeof(TMessage)]);
/// <summary>
/// Adds the specified type to the set of declared "sent" messagetypes for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <param name="messageType">The type to be declared.</param>
/// <returns></returns>
public ProtocolBuilder SendsMessageType(Type messageType) => this.SendsMessageTypes([messageType]);
/// <summary>
/// Adds the specified types to the set of declared "sent" message types for the protocol. Objects of these types will be allowed to be
/// sent through the Executor's outgoing edges, via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
/// <param name="messageTypes">A set of types to be declared.</param>
/// <returns></returns>
public ProtocolBuilder SendsMessageTypes(IEnumerable<Type> messageTypes)
{
Throw.IfNull(messageTypes);
this._sendTypes.UnionWith(messageTypes);
return this;
}
/// <summary>
/// Adds the specified output type to the set of declared "yielded" output types for the protocol. Objects of this type will be
/// allowed to be output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <typeparam name="TOutput">The type to be declared.</typeparam>
/// <returns></returns>
public ProtocolBuilder YieldsOutput<TOutput>() where TOutput : notnull => this.YieldsOutputTypes([typeof(TOutput)]);
/// <summary>
/// Adds the specified output type to the set of declared "yielded" output types for the protocol. Objects of this type will be
/// allowed to be output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <param name="outputType">The type to be declared.</param>
/// <returns></returns>
public ProtocolBuilder YieldsOutputType(Type outputType) => this.YieldsOutputTypes([outputType]);
/// <summary>
/// Adds the specified types to the set of declared "yielded" output types for the protocol. Objects of these types will be allowed to be
/// output from the executor through the <see cref="WorkflowOutputEvent"/>, via <see cref="IWorkflowContext.YieldOutputAsync"/>.
/// </summary>
/// <param name="yieldedTypes">A set of types to be declared.</param>
/// <returns></returns>
public ProtocolBuilder YieldsOutputTypes(IEnumerable<Type> yieldedTypes)
{
Throw.IfNull(yieldedTypes);
this._yieldTypes.UnionWith(yieldedTypes);
return this;
}
/// <summary>
/// Gets a route builder to configure message handlers.
/// </summary>
public RouteBuilder RouteBuilder { get; }
/// <summary>
/// Fluently configures message handlers.
/// </summary>
/// <param name="configureAction">The handler configuration callback.</param>
/// <returns></returns>
public ProtocolBuilder ConfigureRoutes(Action<RouteBuilder> configureAction)
{
configureAction(this.RouteBuilder);
return this;
}
internal ExecutorProtocol Build(ExecutorOptions options)
{
MessageRouter router = this.RouteBuilder.Build();
HashSet<Type> sendTypes = new(this._sendTypes);
if (options.AutoSendMessageHandlerResultObject)
{
sendTypes.UnionWith(router.DefaultOutputTypes);
}
HashSet<Type> yieldTypes = new(this._yieldTypes);
if (options.AutoYieldOutputHandlerResultObject)
{
yieldTypes.UnionWith(router.DefaultOutputTypes);
}
return new(router, sendTypes, yieldTypes);
}
}
@@ -16,14 +16,27 @@ public class ProtocolDescriptor
/// </summary>
public IEnumerable<Type> Accepts { get; }
/// <summary>
/// Gets the collection of types that could be yielded as output by the <see cref="Workflow"/> or <see cref="Executor"/>.
/// </summary>
public IEnumerable<Type> Yields { get; }
/// <summary>
/// Gets the collection of types that could be sent from the <see cref="Executor"/>. This is always empty for a <see cref="Workflow"/>.
/// </summary>
public IEnumerable<Type> Sends { get; }
/// <summary>
/// Gets a value indicating whether the <see cref="Workflow"/> or <see cref="Executor"/> has a "catch-all" handler.
/// </summary>
public bool AcceptsAll { get; set; }
internal ProtocolDescriptor(IEnumerable<Type> acceptedTypes, bool acceptsAll)
internal ProtocolDescriptor(IEnumerable<Type> acceptedTypes, IEnumerable<Type> yieldedTypes, IEnumerable<Type> sentTypes, bool acceptsAll)
{
this.Accepts = acceptedTypes.ToArray();
this.Yields = yieldedTypes.ToArray();
this.Sends = sentTypes.ToArray();
this.AcceptsAll = acceptsAll;
}
}
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
namespace Microsoft.Agents.AI.Workflows.Reflection;
@@ -29,7 +32,45 @@ public class ReflectingExecutor<
{
}
/// <inheritdoc />
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.ReflectHandlers(this);
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.SendsMessageTypes(typeof(TExecutor).GetCustomAttributes<SendsMessageAttribute>(inherit: true)
.Select(attr => attr.Type))
.YieldsOutputTypes(typeof(TExecutor).GetCustomAttributes<YieldsOutputAttribute>(inherit: true)
.Select(attr => attr.Type));
List<MessageHandlerInfo> messageHandlers = typeof(TExecutor).GetHandlerInfos().ToList();
foreach (MessageHandlerInfo handlerInfo in messageHandlers)
{
protocolBuilder.RouteBuilder.AddHandlerInternal(handlerInfo.InType, handlerInfo.Bind(this, checkType: true), handlerInfo.OutType);
if (handlerInfo.OutType != null)
{
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessageType(handlerInfo.OutType);
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutputType(handlerInfo.OutType);
}
}
}
if (messageHandlers.Count > 0)
{
var handlerAnnotatedTypes =
messageHandlers.Select(mhi => (SendTypes: mhi.HandlerInfo.GetCustomAttributes<SendsMessageAttribute>().Select(attr => attr.Type),
YieldTypes: mhi.HandlerInfo.GetCustomAttributes<YieldsOutputAttribute>().Select(attr => attr.Type)))
.Aggregate((accumulate, next) => (accumulate.SendTypes == null ? next.SendTypes : accumulate.SendTypes.Concat(next.SendTypes),
accumulate.YieldTypes == null ? next.YieldTypes : accumulate.YieldTypes.Concat(next.YieldTypes)));
protocolBuilder.SendsMessageTypes(handlerAnnotatedTypes.SendTypes)
.YieldsOutputTypes(handlerAnnotatedTypes.YieldTypes);
}
return protocolBuilder;
}
}
@@ -7,7 +7,6 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Reflection;
@@ -45,7 +44,7 @@ internal static class IMessageHandlerReflection
internal static class RouteBuilderExtensions
{
private static IEnumerable<MessageHandlerInfo> GetHandlerInfos(
public static IEnumerable<MessageHandlerInfo> GetHandlerInfos(
[DynamicallyAccessedMembers(ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)]
this Type executorType)
{
@@ -77,25 +76,4 @@ internal static class RouteBuilderExtensions
}
}
}
public static RouteBuilder ReflectHandlers<
[DynamicallyAccessedMembers(
ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation)
] TExecutor>
(this RouteBuilder builder, ReflectingExecutor<TExecutor> executor)
where TExecutor : ReflectingExecutor<TExecutor>
{
Throw.IfNull(builder);
Type executorType = typeof(TExecutor);
Debug.Assert(executorType.IsInstanceOfType(executor),
"executorType must be the same type or a base type of the executor instance.");
foreach (MessageHandlerInfo handlerInfo in executorType.GetHandlerInfos())
{
builder = builder.AddHandlerInternal(handlerInfo.InType, handlerInfo.Bind(executor, checkType: true), handlerInfo.OutType);
}
return builder;
}
}
@@ -35,9 +35,6 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for configuring message type handlers for an <see cref="Executor"/>.
/// </summary>
/// <remarks>
/// Override the <see cref="Executor.ConfigureRoutes"/> method to customize the routing of messages to handlers.
/// </remarks>
public class RouteBuilder
{
private readonly IExternalRequestContext? _externalRequestContext;
@@ -631,6 +628,8 @@ public class RouteBuilder
}
}
internal IEnumerable<Type> OutputTypes => this._outputTypes.Values;
internal MessageRouter Build()
{
if (this._portHandlers.Count > 0)
@@ -36,27 +36,26 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
this._options = options;
}
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder)
{
this._userInputHandler = new AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>(
ref routeBuilder,
ref protocolBuilder,
portId: $"{this.Id}_UserInput",
intercepted: this._options.InterceptUserInputRequests,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref routeBuilder,
ref protocolBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: this._options.InterceptUnterminatedFunctionCalls,
handler: this.HandleFunctionResultAsync);
return routeBuilder;
return protocolBuilder;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
routeBuilder = base.ConfigureRoutes(routeBuilder);
return this.ConfigureUserInputRoutes(routeBuilder);
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder));
}
private ValueTask HandleUserInputResponseAsync(
@@ -18,16 +18,28 @@ internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent
private readonly PortBinding? _portBinding;
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
public AIContentExternalHandler(ref RouteBuilder routeBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
public AIContentExternalHandler(ref ProtocolBuilder protocolBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
{
PortBinding? portBinding = null;
protocolBuilder = protocolBuilder.ConfigureRoutes(routeBuilder => ConfigureRoutes(routeBuilder, out portBinding));
this._portBinding = portBinding;
if (intercepted)
{
this._portBinding = null;
routeBuilder = routeBuilder.AddHandler(handler);
protocolBuilder = protocolBuilder.SendsMessage<TRequestContent>();
}
else
void ConfigureRoutes(RouteBuilder routeBuilder, out PortBinding? portBinding)
{
routeBuilder = routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out this._portBinding);
if (intercepted)
{
portBinding = null;
routeBuilder.AddHandler(handler);
}
else
{
routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out portBinding);
}
}
}
@@ -11,8 +11,10 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
/// Provides an executor that aggregates received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, s_options, declareCrossRunShareable: true), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new() { AutoSendTurnToken = false };
/// <inheritdoc/>
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(messages, cancellationToken: cancellationToken);
@@ -36,8 +36,9 @@ internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
this._remaining = this._expectedInputs;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<List<ChatMessage>>(async (messages, context, cancellationToken) =>
{
// TODO: https://github.com/microsoft/agent-framework/issues/784
// This locking should not be necessary.
@@ -58,6 +59,9 @@ internal sealed class ConcurrentEndExecutor : Executor, IResettableExecutor
}
});
return protocolBuilder.YieldsOutput<List<ChatMessage>>();
}
public ValueTask ResetAsync()
{
this.Reset();
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -11,52 +12,51 @@ internal sealed class GroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorBinding> agentMap,
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : ChatProtocolExecutor(id, s_options), IResettableExecutor
{
private static readonly ChatProtocolExecutorOptions s_options = new()
{
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private readonly List<ChatMessage> _pendingMessages = [];
private GroupChatManager? _manager;
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder
.AddHandler<string>((message, context, _) => this._pendingMessages.Add(new(ChatRole.User, message)))
.AddHandler<ChatMessage>((message, context, _) => this._pendingMessages.Add(message))
.AddHandler<IEnumerable<ChatMessage>>((messages, _, __) => this._pendingMessages.AddRange(messages))
.AddHandler<ChatMessage[]>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<List<ChatMessage>>((messages, _, __) => this._pendingMessages.AddRange(messages)) // TODO: Remove once https://github.com/microsoft/agent-framework/issues/782 is addressed
.AddHandler<TurnToken>(async (token, context, cancellationToken) =>
{
List<ChatMessage> messages = [.. this._pendingMessages];
this._pendingMessages.Clear();
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> base.ConfigureProtocol(protocolBuilder).YieldsOutput<List<ChatMessage>>();
this._manager ??= this._managerFactory(this._agents);
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(token, executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
});
public ValueTask ResetAsync()
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
this._manager ??= this._managerFactory(this._agents);
if (!await this._manager.ShouldTerminateAsync(messages, cancellationToken).ConfigureAwait(false))
{
var filtered = await this._manager.UpdateHistoryAsync(messages, cancellationToken).ConfigureAwait(false);
messages = filtered is null || ReferenceEquals(filtered, messages) ? messages : [.. filtered];
if (await this._manager.SelectNextAgentAsync(messages, cancellationToken).ConfigureAwait(false) is AIAgent nextAgent &&
this._agentMap.TryGetValue(nextAgent, out var executor))
{
this._manager.IterationCount++;
await context.SendMessageAsync(messages, executor.Id, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(new TurnToken(emitEvents), executor.Id, cancellationToken).ConfigureAwait(false);
return;
}
}
this._manager = null;
await context.YieldOutputAsync(messages, cancellationToken).ConfigureAwait(false);
}
protected override ValueTask ResetAsync()
{
this._pendingMessages.Clear();
this._manager = null;
return default;
return base.ResetAsync();
}
ValueTask IResettableExecutor.ResetAsync() => this.ResetAsync();
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +15,7 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
internal sealed class HandoffAgentExecutor(
AIAgent agent,
string? handoffInstructions) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
string? handoffInstructions) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
@@ -60,59 +61,56 @@ internal sealed class HandoffAgentExecutor(
sb.WithDefault(end);
});
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>(async (handoffState, context, cancellationToken) =>
public override async ValueTask<HandoffState> HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = message.Messages;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
foreach (var fcc in update.Contents.OfType<FunctionCallContent>()
.Where(fcc => this._handoffFunctionNames.Contains(fcc.Name)))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var c in update.Contents)
{
if (c is FunctionCallContent fcc && this._handoffFunctionNames.Contains(fcc.Name))
{
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
}
allMessages.AddRange(updates.ToAgentResponse().Messages);
allMessages.AddRange(updates.ToAgentResponse().Messages);
roleChanges.ResetUserToAssistantForChangedRoles();
roleChanges.ResetUserToAssistantForChangedRoles();
await context.SendMessageAsync(new HandoffState(handoffState.TurnToken, requestedHandoff, allMessages), cancellationToken: cancellationToken).ConfigureAwait(false);
return new(message.TurnToken, requestedHandoff, allMessages);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
if (message.TurnToken.EmitEvents is true)
{
updates.Add(update);
if (handoffState.TurnToken.EmitEvents is true)
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
});
}
}
public ValueTask ResetAsync() => default;
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
@@ -9,9 +11,10 @@ internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossR
{
public const string ExecutorId = "HandoffEnd";
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler<HandoffState>((handoff, context, cancellationToken) =>
context.YieldOutputAsync(handoff.Messages, cancellationToken)))
.YieldsOutput<List<ChatMessage>>();
public ValueTask ResetAsync() => default;
}
@@ -14,9 +14,13 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
StringMessageChatRole = ChatRole.User,
AutoSendTurnToken = false
};
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder).SendsMessage<HandoffState>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
@@ -15,6 +15,10 @@ internal sealed class OutputMessagesExecutor(ChatProtocolExecutorOptions? option
{
public const string ExecutorId = "OutputMessages";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder)
.YieldsOutput<List<ChatMessage>>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> context.YieldOutputAsync(messages, cancellationToken);
@@ -34,22 +34,29 @@ internal sealed class RequestInfoExecutor : Executor
this._allowWrapped = allowWrapped;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
routeBuilder = routeBuilder
// Handle incoming requests (as raw request payloads)
.AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ExternalRequest>()
.SendsMessageType(this.Port.Response);
if (this._allowWrapped)
void ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = routeBuilder
.AddHandler<ExternalRequest, ExternalRequest>(this.HandleAsync);
}
// Handle incoming requests (as raw request payloads)
.AddHandlerUntyped(this.Port.Request, this.HandleAsync)
.AddCatchAll(this.HandleCatchAllAsync);
return routeBuilder
// Handle incoming responses (as wrapped Response object)
.AddHandler<ExternalResponse, ExternalResponse?>(this.HandleAsync);
if (this._allowWrapped)
{
routeBuilder = routeBuilder
.AddHandler<ExternalRequest, ExternalRequest>(this.HandleAsync);
}
routeBuilder
// Handle incoming responses (as wrapped Response object)
.AddHandler<ExternalResponse, ExternalResponse?>(this.HandleAsync);
}
}
internal void AttachRequestSink(IExternalRequestSink requestSink) => this.RequestSink = Throw.IfNull(requestSink);
@@ -17,6 +17,7 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
{
private readonly string _runId;
private readonly Workflow _workflow;
private readonly ProtocolDescriptor _workflowProtocol;
private readonly object _ownershipToken;
private InProcessRunner? _activeRunner;
@@ -30,19 +31,26 @@ internal class WorkflowHostExecutor : Executor, IAsyncDisposable
[MemberNotNullWhen(true, nameof(_checkpointManager))]
private bool WithCheckpointing => this._checkpointManager != null;
public WorkflowHostExecutor(string id, Workflow workflow, string runId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
public WorkflowHostExecutor(string id, Workflow workflow, ProtocolDescriptor workflowProtocol, string runId, object ownershipToken, ExecutorOptions? options = null) : base(id, options)
{
this._options = options ?? new();
Throw.IfNull(workflow);
//Throw.IfNull(workflow);
this._runId = Throw.IfNull(runId);
this._ownershipToken = Throw.IfNull(ownershipToken);
this._workflow = Throw.IfNull(workflow);
this._workflowProtocol = Throw.IfNull(workflowProtocol);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder.AddCatchAll(this.QueueExternalMessageAsync);
if (this._options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder = protocolBuilder.YieldsOutputTypes(this._workflowProtocol.Yields);
}
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddCatchAll(this.QueueExternalMessageAsync))
.SendsMessageTypes(this._workflowProtocol.Yields);
}
private async ValueTask QueueExternalMessageAsync(PortableValue portableValue, IWorkflowContext context, CancellationToken cancellationToken)
@@ -3,6 +3,7 @@
#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
@@ -136,7 +137,7 @@ public abstract class StatefulExecutor<TState> : Executor
}
/// <inheritdoc cref="IResettableExecutor.ResetAsync"/>
protected ValueTask ResetAsync()
protected virtual ValueTask ResetAsync()
{
this._stateCache = this._initialStateFactory();
@@ -153,13 +154,25 @@ public abstract class StatefulExecutor<TState> : Executor
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
public abstract class StatefulExecutor<TState, TInput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput>
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput>(this.HandleAsync);
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -175,13 +188,35 @@ public abstract class StatefulExecutor<TState, TInput>(string id, Func<TState> i
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="initialStateFactory">A factory to initialize the state value to be used by the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="sentMessageTypes">Message types sent by the handler. Defaults to empty, and will filter out non-matching messages.</param>
/// <param name="outputTypes">Message types yielded as output by the handler. Defaults to empty.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id, Func<TState> initialStateFactory, StatefulExecutorOptions? options = null, bool declareCrossRunShareable = false)
public abstract class StatefulExecutor<TState, TInput, TOutput>(string id,
Func<TState> initialStateFactory,
StatefulExecutorOptions? options = null,
IEnumerable<Type>? sentMessageTypes = null,
IEnumerable<Type>? outputTypes = null,
bool declareCrossRunShareable = false)
: StatefulExecutor<TState>(id, initialStateFactory, options, declareCrossRunShareable), IMessageHandler<TInput, TOutput>
where TOutput : notnull
{
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessage<TOutput>();
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutput<TOutput>();
}
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
public abstract ValueTask<TOutput> HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default);
@@ -27,9 +27,11 @@ public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorO
return InitHostExecutorAsync;
ValueTask<Executor> InitHostExecutorAsync(string runId)
async ValueTask<Executor> InitHostExecutorAsync(string runId)
{
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
ProtocolDescriptor workflowProtocol = await workflow.DescribeProtocolAsync().ConfigureAwait(false);
return new WorkflowHostExecutor(id, workflow, workflowProtocol, runId, ownershipToken, options);
}
}
@@ -218,8 +218,14 @@ public class Workflow
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
startExecutor.Configure(new NoOpExternalRequestContext());
startExecutor.AttachRequestContext(new NoOpExternalRequestContext());
return startExecutor.DescribeProtocol();
ProtocolDescriptor inputProtocol = startExecutor.DescribeProtocol();
IEnumerable<Task<Executor>> outputExecutorTasks = this.OutputExecutors.Select(executorId => this.ExecutorBindings[executorId].CreateInstanceAsync(string.Empty).AsTask());
Executor[] outputExecutors = await Task.WhenAll(outputExecutorTasks).ConfigureAwait(false);
IEnumerable<Type> yieldedTypes = outputExecutors.SelectMany(executor => executor.DescribeProtocol().Yields);
return new(inputProtocol.Accepts, yieldedTypes, [], inputProtocol.AcceptsAll);
}
}
@@ -17,9 +17,9 @@ public class DevUIIntegrationTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
[Fact]
@@ -125,6 +125,7 @@ public abstract class WorkflowActionExecutorTest(ITestOutputHelper output) : Wor
internal sealed class TestWorkflowExecutor() : Executor<WorkflowFormulaState>("test_workflow")
{
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(WorkflowFormulaState message, IWorkflowContext context, CancellationToken cancellationToken) =>
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -38,9 +38,9 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)");
generated.Should().Contain(".AddHandler<string>(this.HandleMessage)");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().AddHandler("this.HandleMessage", "string");
}
[Fact]
@@ -205,9 +205,9 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("protected override ISet<Type> ConfigureYieldTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.OutputMessage))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().RegisterYieldedOutputType("global::TestNamespace.OutputMessage");
}
[Fact]
@@ -236,9 +236,8 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("protected override ISet<Type> ConfigureSentTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.SendMessage))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().RegisterSentMessageType("global::TestNamespace.SendMessage");
}
[Fact]
@@ -268,9 +267,8 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("protected override ISet<Type> ConfigureSentTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
}
[Fact]
@@ -300,9 +298,8 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("protected override ISet<Type> ConfigureYieldTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.YieldedMessage))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().RegisterYieldedOutputType("global::TestNamespace.YieldedMessage");
}
#endregion
@@ -336,20 +333,10 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Verify partial declarations are present
generated.Should().Contain("partial class OuterClass");
generated.Should().Contain("partial class TestExecutor");
// Verify proper nesting structure with braces
// The outer class should open before the inner class
var outerIndex = generated.IndexOf("partial class OuterClass", StringComparison.Ordinal);
var innerIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
outerIndex.Should().BeLessThan(innerIndex, "outer class should appear before inner class");
// Verify handler registration is present
generated.Should().Contain(".AddHandler<string>(this.HandleMessage)");
generated.Should().HaveHierarchy("OuterClass", "TestExecutor")
.And.AddHandler("this.HandleMessage", "string");
}
[Fact]
@@ -382,22 +369,10 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Verify all three partial declarations are present in correct order
generated.Should().Contain("partial class Outer");
generated.Should().Contain("partial class Inner");
generated.Should().Contain("partial class TestExecutor");
var outerIndex = generated.IndexOf("partial class Outer", StringComparison.Ordinal);
var innerIndex = generated.IndexOf("partial class Inner", StringComparison.Ordinal);
var executorIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
outerIndex.Should().BeLessThan(innerIndex, "Outer should appear before Inner");
innerIndex.Should().BeLessThan(executorIndex, "Inner should appear before TestExecutor");
// Verify handler registration
generated.Should().Contain(".AddHandler<string>(this.HandleMessage)");
generated.Should().HaveHierarchy("Outer", "Inner", "TestExecutor")
.And.AddHandler("this.HandleMessage", "string");
}
[Fact]
@@ -433,26 +408,10 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// All four partial class declarations should be present
generated.Should().Contain("partial class Level1");
generated.Should().Contain("partial class Level2");
generated.Should().Contain("partial class Level3");
generated.Should().Contain("partial class TestExecutor");
// Verify correct ordering
var level1Index = generated.IndexOf("partial class Level1", StringComparison.Ordinal);
var level2Index = generated.IndexOf("partial class Level2", StringComparison.Ordinal);
var level3Index = generated.IndexOf("partial class Level3", StringComparison.Ordinal);
var executorIndex = generated.IndexOf("partial class TestExecutor", StringComparison.Ordinal);
level1Index.Should().BeLessThan(level2Index);
level2Index.Should().BeLessThan(level3Index);
level3Index.Should().BeLessThan(executorIndex);
// Verify handler registration
generated.Should().Contain(".AddHandler<int>(this.HandleMessage)");
generated.Should().HaveHierarchy("Level1", "Level2", "Level3", "TestExecutor")
.And.AddHandler("this.HandleMessage", "int");
}
[Fact]
@@ -480,15 +439,11 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Should not contain namespace declaration
generated.Should().NotContain("namespace ");
// Should still have proper partial hierarchy
generated.Should().Contain("partial class OuterClass");
generated.Should().Contain("partial class TestExecutor");
generated.Should().Contain(".AddHandler<string>(this.HandleMessage)");
generated.Should().NotHaveNamespace()
.And.HaveHierarchy("OuterClass", "TestExecutor")
.And.AddHandler("this.HandleMessage", "string");
}
[Fact]
@@ -576,7 +531,7 @@ public class ExecutorRouteGeneratorTests
// - 1 for Outer class
// - 1 for Inner class
// - 1 for TestExecutor class
// - 1 for ConfigureRoutes method
// - 1 for ConfigureProtocol method
// = 4 pairs minimum
openBraces.Should().BeGreaterThanOrEqualTo(4, "should have braces for all nested classes and method");
}
@@ -633,11 +588,11 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Should have both handlers registered
generated.Should().Contain(".AddHandler<string>(this.HandleString)");
generated.Should().Contain(".AddHandler<int>(this.HandleIntAsync)");
generated.Should().AddHandler("this.HandleString", "string")
.And.AddHandler("this.HandleIntAsync", "int");
// Verify the generated code compiles with all three partials combined
var compilationErrors = result.OutputCompilation.GetDiagnostics()
@@ -688,11 +643,11 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Both handlers from different files should be registered
generated.Should().Contain(".AddHandler<string>(this.HandleFromFile1)");
generated.Should().Contain(".AddHandler<int>(this.HandleFromFile2)");
generated.Should().AddHandler("this.HandleFromFile1", "string")
.And.AddHandler("this.HandleFromFile2", "int");
}
[Fact]
@@ -739,29 +694,13 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Verify ConfigureSentTypes override
var sendsStart = generated.IndexOf("protected override ISet<Type> ConfigureSentTypes()", StringComparison.Ordinal);
sendsStart.Should().NotBe(-1, "should generate ConfigureSentTypes override");
var sendsEnd = generated.IndexOf("}", sendsStart, StringComparison.Ordinal);
sendsEnd.Should().NotBe(-1, "should close ConfigureSentTypes override");
generated.Substring(sendsStart, sendsEnd - sendsStart).Should().ContainAll(
"types.Add(typeof(string));",
"types.Add(typeof(int));");
// Verify ConfigureYieldTypes override
var yieldsStart = generated.IndexOf("protected override ISet<Type> ConfigureYieldTypes()", StringComparison.Ordinal);
yieldsStart.Should().NotBe(-1, "should generate ConfigureYieldTypes override");
var yieldsEnd = generated.IndexOf("}", yieldsStart, StringComparison.Ordinal);
yieldsEnd.Should().NotBe(-1, "should close ConfigureYieldTypes override");
generated.Substring(yieldsStart, yieldsEnd - yieldsStart).Should().ContainAll(
"types.Add(typeof(string));",
"types.Add(typeof(int));");
// Verify SendsMessage and YieldsOutput from both partials are combined correctly
generated.Should().RegisterSentMessageType("string")
.And.RegisterSentMessageType("int")
.And.RegisterYieldedOutputType("string")
.And.RegisterYieldedOutputType("string");
}
#endregion
@@ -896,7 +835,7 @@ public class ExecutorRouteGeneratorTests
#region No Generation Tests
[Fact]
public void ClassWithManualConfigureRoutes_DoesNotGenerate()
public void ClassWithManualConfigureProtocol_DoesNotGenerate()
{
var source = """
using System.Threading;
@@ -909,9 +848,9 @@ public class ExecutorRouteGeneratorTests
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder;
return protocolBuilder;
}
[MessageHandler]
@@ -953,130 +892,6 @@ public class ExecutorRouteGeneratorTests
#region Protocol-Only Generation Tests
[Fact]
public void ProtocolOnly_SendsMessage_WithManualRoutes_GeneratesConfigureSentTypes()
{
var source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
namespace TestNamespace;
public class BroadcastMessage { }
[SendsMessage(typeof(BroadcastMessage))]
public partial class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
var result = GeneratorTestHelper.RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
// Should NOT generate ConfigureRoutes (user has manual implementation)
generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
// Should generate ConfigureSentTypes
generated.Should().Contain("protected override ISet<Type> ConfigureSentTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
}
[Fact]
public void ProtocolOnly_YieldsOutput_WithManualRoutes_GeneratesConfigureYieldTypes()
{
var source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
namespace TestNamespace;
public class OutputMessage { }
[YieldsOutput(typeof(OutputMessage))]
public partial class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
var result = GeneratorTestHelper.RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
// Should NOT generate ConfigureRoutes (user has manual implementation)
generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
// Should generate ConfigureYieldTypes
generated.Should().Contain("protected override ISet<Type> ConfigureYieldTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.OutputMessage))");
}
[Fact]
public void ProtocolOnly_BothAttributes_WithManualRoutes_GeneratesBothOverrides()
{
var source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
namespace TestNamespace;
public class SendMessage { }
public class YieldMessage { }
[SendsMessage(typeof(SendMessage))]
[YieldsOutput(typeof(YieldMessage))]
public partial class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
var result = GeneratorTestHelper.RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
// Should NOT generate ConfigureRoutes
generated.Should().NotContain("protected override RouteBuilder ConfigureRoutes");
// Should generate both protocol overrides
generated.Should().Contain("protected override ISet<Type> ConfigureSentTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.SendMessage))");
generated.Should().Contain("protected override ISet<Type> ConfigureYieldTypes()");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.YieldMessage))");
}
[Fact]
public void ProtocolOnly_MultipleSendsMessageAttributes_GeneratesAllTypes()
{
@@ -1098,11 +913,6 @@ public class ExecutorRouteGeneratorTests
public partial class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
@@ -1110,10 +920,11 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageA))");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageB))");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.MessageC))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().RegisterSentMessageType("global::TestNamespace.MessageA")
.And.RegisterSentMessageType("global::TestNamespace.MessageB")
.And.RegisterSentMessageType("global::TestNamespace.MessageC");
}
[Fact]
@@ -1133,11 +944,6 @@ public class ExecutorRouteGeneratorTests
public class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
@@ -1193,11 +999,6 @@ public class ExecutorRouteGeneratorTests
public partial class TestExecutor : Executor
{
public TestExecutor() : base("test") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
}
""";
@@ -1207,14 +1008,12 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
result.RunResult.Diagnostics.Should().BeEmpty();
var generated = result.RunResult.GeneratedTrees[0].ToString();
var generated = result.RunResult.GeneratedTrees[0];
// Verify partial declarations are present
generated.Should().Contain("partial class OuterClass");
generated.Should().Contain("partial class TestExecutor");
generated.Should().HaveHierarchy("OuterClass", "TestExecutor")
// Verify protocol types are generated
generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
}
[Fact]
@@ -1234,11 +1033,6 @@ public class ExecutorRouteGeneratorTests
public partial class GenericExecutor<T> : Executor where T : class
{
public GenericExecutor() : base("generic") { }
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder;
}
}
""";
@@ -1246,9 +1040,10 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("partial class GenericExecutor<T>");
generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().HaveHierarchy("GenericExecutor<T>")
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
}
#endregion
@@ -1278,9 +1073,10 @@ public class ExecutorRouteGeneratorTests
result.RunResult.GeneratedTrees.Should().HaveCount(1);
var generated = result.RunResult.GeneratedTrees[0].ToString();
generated.Should().Contain("partial class GenericExecutor<T>");
generated.Should().Contain(".AddHandler<T>(this.HandleMessage)");
var generated = result.RunResult.GeneratedTrees[0];
generated.Should().HaveHierarchy("GenericExecutor<T>")
.And.AddHandler("this.HandleMessage", "T");
}
#endregion
@@ -0,0 +1,220 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using FluentAssertions;
using FluentAssertions.Execution;
using FluentAssertions.Primitives;
using Microsoft.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.Generators.UnitTests;
internal sealed class SyntaxTreeAssertions : ObjectAssertions<SyntaxTree, SyntaxTreeAssertions>
{
private readonly string _syntaxString;
public SyntaxTreeAssertions(SyntaxTree instance, AssertionChain assertionChain) : base(instance, assertionChain)
{
this._syntaxString = instance.ToString();
}
public AndConstraint<SyntaxTreeAssertions> AddHandler(string handlerName)
{
string expectedRegistration = $".AddHandler({handlerName})";
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains(expectedRegistration))
.BecauseOf($"expected handler {handlerName} to be registered")
.FailWith("Expected {context} to contain handler registration {0}{reason}, but it was not found. Actual syntax: {1}",
expectedRegistration, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> AddHandler(string handlerName, string inTypeParam)
{
string expectedRegistration = $".AddHandler<{inTypeParam}>({handlerName})";
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains(expectedRegistration))
.BecauseOf($"expected handler {handlerName} to be registered")
.FailWith("Expected {context} to contain handler registration {0}{reason}, but it was not found. Actual syntax: {1}",
expectedRegistration, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> AddHandler(string handlerName, string inTypeParam, string outTypeParam)
{
string expectedRegistration = $".AddHandler<{inTypeParam},{outTypeParam}>({handlerName})";
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains(expectedRegistration))
.BecauseOf($"expected handler {handlerName} to be registered")
.FailWith("Expected {context} to contain handler registration {0}{reason}, but it was not found. Actual syntax: {1}",
expectedRegistration, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> AddHandler<TIn>(string handlerName, bool globalQualified = false)
{
Type inType = typeof(TIn);
string inTypeParam = globalQualified ? $"global::{inType.FullName}" : inType.Name;
return this.AddHandler(handlerName, inTypeParam);
}
public AndConstraint<SyntaxTreeAssertions> AddHandler<TIn, TOut>(string handlerName, bool globalQualified = false)
{
Type inType = typeof(TIn), outType = typeof(TOut);
string inTypeParam = globalQualified ? $"global::{inType.FullName}" : inType.Name;
string outTypeParam = globalQualified ? $"global::{outType.FullName}" : outType.Name;
return this.AddHandler(handlerName, inTypeParam, outTypeParam);
}
public AndConstraint<SyntaxTreeAssertions> HaveNoHandlers()
{
this.CurrentAssertionChain
.ForCondition(!this._syntaxString.Contains(".AddHandler("))
.BecauseOf("expected no handlers to be registered")
.FailWith("Expected {context} to have no handler registrations{reason}, but found at least one. Actual syntax: {1}",
this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> RegisterSentMessageType(string messageTypeParam)
{
string expectedRegistration = $".SendsMessage<{messageTypeParam}>()";
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains(expectedRegistration))
.BecauseOf($"expected message type {messageTypeParam} to be registered")
.FailWith("Expected {context} to contain message type registration {0}{reason}, but it was not found. Actual syntax: {1}",
expectedRegistration, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> RegisterSentMessageType<TMessage>(bool globalQualified = true)
{
Type messageType = typeof(TMessage);
string messageTypeParam = globalQualified ? $"global::{messageType.FullName}" : messageType.Name;
return this.RegisterSentMessageType(messageTypeParam);
}
public AndConstraint<SyntaxTreeAssertions> NotRegisterSentMessageTypes()
{
this.CurrentAssertionChain
.ForCondition(!this._syntaxString.Contains(".SendsMessage<"))
.BecauseOf("expected no message types to be registered")
.FailWith("Expected {context} to have no message type registrations{reason}, but found at least one. Actual syntax: {1}",
this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> RegisterYieldedOutputType(string outputTypeParam)
{
string expectedRegistration = $".YieldsOutput<{outputTypeParam}>()";
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains(expectedRegistration))
.BecauseOf($"expected output type {outputTypeParam} to be registered")
.FailWith("Expected {context} to contain output type registration {0}{reason}, but it was not found. Actual syntax: {1}",
expectedRegistration, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> RegisterYieldedOutputType<TOutput>(bool globalQualified = true)
{
Type outputType = typeof(TOutput);
string outputTypeParam = globalQualified ? $"global::{outputType.FullName}" : outputType.Name;
return this.RegisterYieldedOutputType(outputTypeParam);
}
public AndConstraint<SyntaxTreeAssertions> NotRegisterYieldedOutputTypes()
{
this.CurrentAssertionChain
.ForCondition(!this._syntaxString.Contains(".YieldsOutput<"))
.BecauseOf("expected no output types to be registered")
.FailWith("Expected {context} to have no output type registrations{reason}, but found at least one. Actual syntax: {1}",
this._syntaxString);
return new(this);
}
private AndConstraint<SyntaxTreeAssertions> ContainPartialDeclaration(int level, int index, string className)
{
this.CurrentAssertionChain
.ForCondition(index > 0)
.BecauseOf($"expected \"partial class {className}\" at nesting level {level}")
.FailWith("Expected {context} to contain \"partial class {0}\" at nesting level {1}{reason}, but it was not found. Actual syntax: {2}",
className, level, this._syntaxString);
return new(this);
}
private AndConstraint<SyntaxTreeAssertions> DeclarePartialsInCorrectOrder(int prevIndex, int currIndex, string prevClass, string currClass)
{
this.CurrentAssertionChain
.ForCondition(prevIndex < currIndex)
.BecauseOf($"expected \"partial class {prevClass}\" before \"partial class {currClass}\"")
.FailWith("Expected {context} to have \"partial class {0}\" before \"partial class {1}\"{reason}, but the order was incorrect. Actual syntax: {2}",
prevClass, currClass, this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> HaveHierarchy(params string[] expectedNesting)
{
if (expectedNesting.Length == 0)
{
return new AndConstraint<SyntaxTreeAssertions>(this);
}
int[] indicies = new int[expectedNesting.Length];
for (int i = 0; i < expectedNesting.Length; i++)
{
indicies[i] = this._syntaxString.IndexOf($"partial class {expectedNesting[i]}", StringComparison.Ordinal);
}
// Verify partial declarations are present
AndConstraint<SyntaxTreeAssertions> runningResult = this.ContainPartialDeclaration(0, indicies[0], expectedNesting[0]);
for (int i = 1; i < expectedNesting.Length; i++)
{
runningResult = runningResult.And.ContainPartialDeclaration(i, indicies[i], expectedNesting[i])
.And.DeclarePartialsInCorrectOrder(indicies[i - 1], indicies[i], expectedNesting[i - 1], expectedNesting[i]);
}
return runningResult;
}
public AndConstraint<SyntaxTreeAssertions> HaveNamespace()
{
this.CurrentAssertionChain
.ForCondition(this._syntaxString.Contains("namespace "))
.BecauseOf("expected namespace declaration")
.FailWith("Expected {context} to contain a namespace declaration{reason}, but it was found. Actual syntax: {0}",
this._syntaxString);
return new(this);
}
public AndConstraint<SyntaxTreeAssertions> NotHaveNamespace()
{
this.CurrentAssertionChain
.ForCondition(!this._syntaxString.Contains("namespace "))
.BecauseOf("expected no namespace declaration")
.FailWith("Expected {context} to not contain a namespace declaration{reason}, but it was found. Actual syntax: {0}",
this._syntaxString);
return new(this);
}
}
internal static class SyntaxTreeFluentExtensions
{
public static SyntaxTreeAssertions Should(this SyntaxTree syntaxTree) => new(syntaxTree, AssertionChain.GetOrCreate());
}
@@ -405,6 +405,10 @@ public class AgentWorkflowBuilderTests
output = e;
break;
}
else if (evt is WorkflowErrorEvent errorEvent)
{
Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
}
}
return (sb.ToString(), output?.As<List<ChatMessage>>());
@@ -65,7 +65,7 @@ public class ChatProtocolExecutorTests
];
// Act - Send List<ChatMessage> via ExecuteAsync
await executor.ExecuteAsync(messages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteCoreAsync(messages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
@@ -90,7 +90,7 @@ public class ChatProtocolExecutorTests
];
// Act - Send as ChatMessage[]
await executor.ExecuteAsync(messages, new TypeId(typeof(ChatMessage[])), context);
await executor.ExecuteCoreAsync(messages, new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
@@ -111,7 +111,7 @@ public class ChatProtocolExecutorTests
var message = new ChatMessage(ChatRole.User, "Single message");
// Act - Send as single ChatMessage
await executor.ExecuteAsync(message, new TypeId(typeof(ChatMessage)), context);
await executor.ExecuteCoreAsync(message, new TypeId(typeof(ChatMessage)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
// Assert
@@ -127,13 +127,13 @@ public class ChatProtocolExecutorTests
TestWorkflowContext context = new(executor.Id);
// Send multiple message batches before taking a turn
await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context);
await executor.ExecuteAsync(new List<ChatMessage>
await executor.ExecuteCoreAsync(new ChatMessage(ChatRole.User, "Message 1"), new TypeId(typeof(ChatMessage)), context);
await executor.ExecuteCoreAsync(new List<ChatMessage>
{
new(ChatRole.User, "Message 2"),
new(ChatRole.User, "Message 3")
}, new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteAsync(new ChatMessage[] { new(ChatRole.User, "Message 4") }, new TypeId(typeof(ChatMessage[])), context);
await executor.ExecuteCoreAsync(new ChatMessage[] { new(ChatRole.User, "Message 4") }, new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
@@ -144,7 +144,7 @@ public class ChatProtocolExecutorTests
executor.ReceivedMessages.Clear();
// Second turn should process new messages only
await executor.ExecuteAsync(new List<ChatMessage>
await executor.ExecuteCoreAsync(new List<ChatMessage>
{
new(ChatRole.User, "Second batch")
}, new TypeId(typeof(List<ChatMessage>)), context);
@@ -165,7 +165,7 @@ public class ChatProtocolExecutorTests
});
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync("String message", new TypeId(typeof(string)), context);
await executor.ExecuteCoreAsync("String message", new TypeId(typeof(string)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
@@ -179,8 +179,8 @@ public class ChatProtocolExecutorTests
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage>(), new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteAsync(Array.Empty<ChatMessage>(), new TypeId(typeof(ChatMessage[])), context);
await executor.ExecuteCoreAsync(new List<ChatMessage>(), new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteCoreAsync(Array.Empty<ChatMessage>(), new TypeId(typeof(ChatMessage[])), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().BeEmpty();
@@ -198,7 +198,7 @@ public class ChatProtocolExecutorTests
var sourceMessages = new[] { new ChatMessage(ChatRole.User, "Test message") };
object messagesToSend = collectionType == typeof(List<ChatMessage>) ? sourceMessages.ToList() : sourceMessages;
await executor.ExecuteAsync(messagesToSend, new TypeId(collectionType), context);
await executor.ExecuteCoreAsync(messagesToSend, new TypeId(collectionType), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
@@ -211,12 +211,12 @@ public class ChatProtocolExecutorTests
TestChatProtocolExecutor executor = new();
TestWorkflowContext context = new(executor.Id);
await executor.ExecuteAsync(new List<ChatMessage> { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteCoreAsync(new List<ChatMessage> { new(ChatRole.User, "Turn 1") }, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(1);
await executor.ExecuteAsync(new ChatMessage(ChatRole.User, "Turn 2"), new TypeId(typeof(ChatMessage)), context);
await executor.ExecuteCoreAsync(new ChatMessage(ChatRole.User, "Turn 2"), new TypeId(typeof(ChatMessage)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().HaveCount(2);
@@ -233,7 +233,7 @@ public class ChatProtocolExecutorTests
List<ChatMessage> initialMessages = [new ChatMessage(ChatRole.User, "Kick off the workflow")];
await executor.ExecuteAsync(initialMessages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.ExecuteCoreAsync(initialMessages, new TypeId(typeof(List<ChatMessage>)), context);
await executor.TakeTurnAsync(new TurnToken(emitEvents: false), context);
executor.ReceivedMessages.Should().NotBeEmpty();
@@ -12,22 +12,25 @@ internal sealed class DynamicPortsExecutor<TRequest, TResponse>(string id, param
public ConcurrentDictionary<string, ConcurrentQueue<TResponse>> ReceivedResponses { get; } = new();
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
foreach (string portId in ports)
return protocolBuilder.ConfigureRoutes(ConfigureRoutes);
void ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = routeBuilder
.AddPortHandler<TRequest, TResponse>(portId,
(response, context, cancellationToken) =>
{
this.ReceivedResponses.GetOrAdd(portId, _ => new()).Enqueue(response);
return default;
}, out PortBinding? binding);
foreach (string portId in ports)
{
routeBuilder = routeBuilder
.AddPortHandler<TRequest, TResponse>(portId,
(response, context, cancellationToken) =>
{
this.ReceivedResponses.GetOrAdd(portId, _ => new()).Enqueue(response);
return default;
}, out PortBinding? binding);
this.PortBindings[portId] = binding;
this.PortBindings[portId] = binding;
}
}
return routeBuilder;
}
public ValueTask PostRequestAsync(string portId, TRequest request, TestRunContext testContext, string? requestId = null)
@@ -21,7 +21,7 @@ public class DynamicRequestPortTests
public RequestPortTestContext()
{
this.Executor = new(ExecutorId, PortId);
this.Executor.Configure(this.ExternalRequestContext);
this.Executor.AttachRequestContext(this.ExternalRequestContext);
}
public TestRunContext RunContext { get; } = new();
@@ -50,7 +50,7 @@ public class DynamicRequestPortTests
}
public ValueTask<object?> InvokeExecutorWithResponseAsync(ExternalResponse response)
=> this.Executor.ExecuteAsync(response, new(typeof(ExternalResponse)), this.RunContext.BindWorkflowContext(this.Executor.Id));
=> this.Executor.ExecuteCoreAsync(response, new(typeof(ExternalResponse)), this.RunContext.BindWorkflowContext(this.Executor.Id));
}
private sealed class ExternalRequestContext : IExternalRequestContext, IExternalRequestSink
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Execution;
@@ -39,7 +40,7 @@ public class EdgeRunnerTests
MessageEnvelope envelope = new(MessageVariant1, "executor1", targetId: targetId);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null, CancellationToken.None);
bool expectMessage = (!conditionMatch.HasValue || conditionMatch.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
@@ -101,7 +102,7 @@ public class EdgeRunnerTests
MessageEnvelope envelope = new("test", "executor1", targetId: targetId);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(envelope, stepTracer: null, CancellationToken.None);
bool expectForwardFrom2 = (!assignerSelectsEmpty.HasValue || !assignerSelectsEmpty.Value)
&& (!targetMatch.HasValue || targetMatch.Value);
@@ -178,22 +179,22 @@ public class EdgeRunnerTests
{
//await runner.ChaseAsync("executor1", new("part1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(new("part1", "executor1"), stepTracer: null);
DeliveryMapping? mapping = await runner.ChaseEdgeAsync(new("part1", "executor1"), stepTracer: null, CancellationToken.None);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor2", new("part-for-1", targetId: "executor1"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part-for-1", "executor2", targetId: "executor1"), stepTracer: null);
mapping = await runner.ChaseEdgeAsync(new("part-for-1", "executor2", targetId: "executor1"), stepTracer: null, CancellationToken.None);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor1", new("part2", targetId: "executor3"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages);
mapping = await runner.ChaseEdgeAsync(new("part2", "executor1", targetId: "executor3"), stepTracer: null);
mapping = await runner.ChaseEdgeAsync(new("part2", "executor1", targetId: "executor3"), stepTracer: null, CancellationToken.None);
mapping.Should().BeNull();
//await runner.ChaseAsync("executor2", new("final part"), state, tracer: null);
//MessageDeliveryValidation.CheckForwarded(runContext.QueuedMessages, ("executor3", ["part1", "part2", "final part"]));
mapping = await runner.ChaseEdgeAsync(new("final part", "executor2"), stepTracer: null);
mapping = await runner.ChaseEdgeAsync(new("final part", "executor2"), stepTracer: null, CancellationToken.None);
mapping.Should().NotBeNull();
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
}
@@ -4,6 +4,10 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class ForwardMessageExecutor<TMessage>(string id) : Executor(id) where TMessage : notnull
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TMessage>((message, ctx) => ctx.SendMessageAsync(message));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TMessage>((message, ctx) => ctx.SendMessageAsync(message));
return protocolBuilder.SendsMessage<TMessage>();
}
}
@@ -7,7 +7,7 @@ using FluentAssertions;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class InProcessStateTests
public partial class InProcessStateTests
{
private sealed class TurnToken
{
@@ -7,6 +7,10 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="true"
/>
</ItemGroup>
<ItemGroup>
@@ -19,7 +19,7 @@ public class RepresentationTests
{
private sealed class TestExecutor() : Executor("TestExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) => routeBuilder;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => protocolBuilder;
}
private sealed class TestAgent : AIAgent
@@ -7,7 +7,6 @@ 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;
@@ -29,6 +28,7 @@ internal static class Step1EntryPoint
public static async ValueTask RunAsync(TextWriter writer, IWorkflowExecutionEnvironment environment)
{
// TODO: Potentially normalize terminology viz Agent.RunStreamingAsync
StreamingRun run = await environment.StreamAsync(WorkflowInstance, input: "Hello, World!").ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
@@ -41,14 +41,19 @@ internal static class Step1EntryPoint
}
}
internal sealed class UppercaseExecutor() : ReflectingExecutor<UppercaseExecutor>("UppercaseExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
internal sealed class UppercaseExecutor() : Executor<string, string>(nameof(UppercaseExecutor), declareCrossRunShareable: true)
{
public async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
public override async ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
message.ToUpperInvariant();
}
internal sealed class ReverseTextExecutor() : ReflectingExecutor<ReverseTextExecutor>("ReverseTextExecutor", declareCrossRunShareable: true), IMessageHandler<string, string>
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());
@@ -8,6 +8,7 @@ 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);
@@ -46,6 +46,9 @@ internal static class Step2EntryPoint
case ExecutorCompletedEvent executorCompletedEvt:
writer.WriteLine($"'{executorCompletedEvt.ExecutorId}: {executorCompletedEvt.Data}");
break;
case WorkflowErrorEvent errorEvent:
Assert.Fail($"Workflow failed with error: {errorEvent.Exception}");
break;
}
}
@@ -60,10 +63,11 @@ internal sealed class DetectSpamExecutor(string id, params string[] spamKeywords
spamKeywords.Any(keyword => message.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) >= 0);
}
internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<RespondToMessageExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
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)
@@ -79,10 +83,11 @@ internal sealed class RespondToMessageExecutor(string id) : ReflectingExecutor<R
}
}
internal sealed class RemoveSpamExecutor(string id) : ReflectingExecutor<RemoveSpamExecutor>(id, declareCrossRunShareable: true), IMessageHandler<bool>
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)
@@ -6,7 +6,6 @@ using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Reflection;
namespace Microsoft.Agents.AI.Workflows.Sample;
@@ -68,7 +67,8 @@ internal enum NumberSignal
Matched
}
internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecutor>, IMessageHandler<NumberSignal, int>
[YieldsOutput(typeof(string))]
internal sealed partial class GuessNumberExecutor : Executor
{
private readonly int _initialLowerBound;
private readonly int _initialUpperBound;
@@ -84,6 +84,7 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
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)
@@ -111,7 +112,8 @@ internal sealed class GuessNumberExecutor : ReflectingExecutor<GuessNumberExecut
}
}
internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessageHandler<int, NumberSignal>
[YieldsOutput(typeof(TryCount))]
internal sealed partial class JudgeExecutor : Executor
{
private readonly int _targetNumber;
@@ -120,6 +122,7 @@ internal sealed class JudgeExecutor : ReflectingExecutor<JudgeExecutor>, IMessag
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.
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -13,9 +14,7 @@ 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 sealed class AllTasksCompletedEvent(IEnumerable<TextProcessingResult> results) : WorkflowEvent(results);
internal static class Step8EntryPoint
internal static partial class Step8EntryPoint
{
public static List<string> TextsToProcess => [
"Hello world! This is a simple test.",
@@ -29,6 +28,7 @@ internal static class Step8EntryPoint
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();
@@ -46,6 +46,22 @@ internal static class Step8EntryPoint
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>()
@@ -62,6 +78,7 @@ internal static class Step8EntryPoint
return results;
}
[YieldsOutput(typeof(TextProcessingResult))]
private static ValueTask ProcessTextAsync(TextProcessingRequest request, IWorkflowContext context, CancellationToken cancellationToken = default)
{
int wordCount = 0;
@@ -76,7 +93,7 @@ internal static class Step8EntryPoint
return context.YieldOutputAsync(new TextProcessingResult(request.TaskId, request.Text, wordCount, charCount), cancellationToken);
}
private sealed class TextProcessingOrchestrator(string id)
private sealed partial class TextProcessingOrchestrator(string id)
: StatefulExecutor<TextProcessingOrchestrator.State>(id, () => new(), declareCrossRunShareable: false)
{
internal sealed class State
@@ -90,13 +107,8 @@ internal static class Step8EntryPoint
public bool CompletePending(string taskId) => this.PendingTaskIds.Remove(taskId);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder.AddHandler<List<string>>(this.StartProcessingAsync)
.AddHandler<TextProcessingResult>(this.CollectResultAsync);
}
private async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context, CancellationToken cancellationToken)
[MessageHandler(Send = [typeof(TextProcessingRequest)])]
public async ValueTask StartProcessingAsync(List<string> texts, IWorkflowContext context, CancellationToken cancellationToken)
{
await this.InvokeWithStateAsync(QueueProcessingTasksAsync, context, cancellationToken: cancellationToken);
@@ -112,7 +124,8 @@ internal static class Step8EntryPoint
}
}
private async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default)
[MessageHandler(Yield = [typeof(List<TextProcessingResult>)])]
public async ValueTask CollectResultAsync(TextProcessingResult result, IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this.InvokeWithStateAsync(CollectResultAndCheckCompletionAsync, context, cancellationToken: cancellationToken);
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
@@ -256,6 +257,22 @@ internal static class Step9EntryPoint
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;
@@ -277,18 +294,26 @@ internal static class Step9EntryPoint
internal sealed class ResourceRequestor() : Executor(nameof(ResourceRequestor), declareCrossRunShareable: true)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.RequestResourcesAsync)
.AddHandler<UserRequest>(InvokeResourceRequestAsync)
.AddHandler<ResourceResponse>(this.HandleResponseAsync)
.AddHandler<PolicyResponse>(this.HandleResponseAsync);
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<ResourceRequest>()
.SendsMessage<PolicyCheckRequest>()
.YieldsOutput<RequestFinished>();
// 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);
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)
@@ -332,12 +357,17 @@ internal sealed class ResourceCache()
["disk"] = 100,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
// 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
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectResultAsync);
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)
@@ -414,10 +444,17 @@ internal sealed class QuotaPolicyEngine()
["disk"] = 1000,
};
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder.AddHandler<ExternalRequest>(this.UnwrapAndHandleRequestAsync)
.AddHandler<ExternalResponse>(this.CollectAndForwardAsync);
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)
@@ -483,17 +520,24 @@ internal sealed class Coordinator() : Executor(nameof(Coordinator), declareCross
{
private const string StateKey = nameof(StateKey);
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return routeBuilder.AddHandler<List<UserRequest>>(this.StartAsync)
.AddHandler<UserRequest>(InvokeStartAsync)
.AddHandler<RequestFinished>(this.HandleFinishedRequestAsync);
return protocolBuilder.ConfigureRoutes(ConfigureRoutes)
.SendsMessage<UserRequest>()
.YieldsOutput<RequestFinished>();
// 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);
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)
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Sample;
/// Tests for shared state preservation across subworkflow boundaries.
/// Validates fix for issue #2419: ".NET: Shared State is not preserved in Subworkflows"
/// </summary>
internal static class Step14EntryPoint
internal static partial class Step14EntryPoint
{
public const string WordStateScope = "WordStateScope";
@@ -106,12 +106,10 @@ internal static class Step14EntryPoint
/// <summary>
/// Executor that reads text and stores it in shared state with a generated key.
/// </summary>
internal sealed class TextReadExecutor() : Executor("TextReadExecutor")
internal sealed partial class TextReadExecutor() : Executor("TextReadExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
private async ValueTask<string> HandleAsync(string text, IWorkflowContext context, CancellationToken cancellationToken = default)
[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);
@@ -122,12 +120,10 @@ internal static class Step14EntryPoint
/// <summary>
/// Executor that reads text from shared state, trims it, and updates the state.
/// </summary>
internal sealed class TextTrimExecutor() : Executor("TextTrimExecutor")
internal sealed partial class TextTrimExecutor() : Executor("TextTrimExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, string>(this.HandleAsync);
private async ValueTask<string> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
[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)
@@ -144,12 +140,10 @@ internal static class Step14EntryPoint
/// <summary>
/// Executor that reads text from shared state and returns its character count.
/// </summary>
internal sealed class CharCountingExecutor() : Executor("CharCountingExecutor")
internal sealed partial class CharCountingExecutor() : Executor("CharCountingExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
=> routeBuilder.AddHandler<string, int>(this.HandleAsync);
private async ValueTask<int> HandleAsync(string key, IWorkflowContext context, CancellationToken cancellationToken = default)
[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;
@@ -27,7 +27,7 @@ public class TestRunContext : IRunnerContext
internal TestRunContext ConfigureExecutor(Executor executor, EdgeMap? map = null)
{
executor.Configure(new TestExternalRequestContext(this, executor.Id, map));
executor.AttachRequestContext(new TestExternalRequestContext(this, executor.Id, map));
this.Executors.Add(executor.Id, executor);
return this;
}
@@ -8,7 +8,7 @@ using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
internal abstract partial class TestingExecutor<TIn, TOut> : Executor, IDisposable
{
private readonly bool _loop;
private readonly Func<TIn, IWorkflowContext, CancellationToken, ValueTask<TOut>>[] _actions;
@@ -39,11 +39,10 @@ internal abstract class TestingExecutor<TIn, TOut> : Executor, IDisposable
public void SetCancel() =>
Volatile.Read(ref this._internalCts).Cancel();
protected sealed override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<TIn, TOut>(this.RouteToActionsAsync);
private int _nextActionIndex;
private ValueTask<TOut> RouteToActionsAsync(TIn message, IWorkflowContext context)
[MessageHandler]
public ValueTask<TOut> RouteToActionsAsync(TIn message, IWorkflowContext context)
{
if (this.AtEnd)
{
@@ -9,16 +9,16 @@ public partial class WorkflowBuilderSmokeTests
{
private sealed class NoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
private sealed class SomeOtherNoOpExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<object>(
(msg, ctx) => ctx.SendMessageAsync(msg));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<object>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
[Fact]
@@ -9,14 +9,16 @@ public class WorkflowVisualizerTests
{
private sealed class MockExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
private sealed class ListStrTargetExecutor(string id) : Executor(id)
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder.AddHandler<string[]>((msgs, ctx) => ctx.SendMessageAsync(string.Join(",", msgs)));
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string[]>((msgs, ctx) => ctx.SendMessageAsync(string.Join(",", msgs))));
}
[Fact]