mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
b05fc9e849
commit
6a3d22598f
+3
@@ -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 = $"""
|
||||
|
||||
+3
@@ -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>
|
||||
|
||||
|
||||
+8
-12
@@ -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 = [];
|
||||
|
||||
|
||||
+5
-1
@@ -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>
|
||||
|
||||
|
||||
+5
-8
@@ -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)
|
||||
|
||||
+9
@@ -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)
|
||||
|
||||
+2
@@ -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.
|
||||
|
||||
+16
@@ -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);
|
||||
|
||||
+1
@@ -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)
|
||||
{
|
||||
|
||||
+1
@@ -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)
|
||||
{
|
||||
|
||||
+2
@@ -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></c> or <c>Executor<TIn, TOut></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)
|
||||
{
|
||||
|
||||
+3
-3
@@ -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., "<T, U>"), 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -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]
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
+57
-261
@@ -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
|
||||
|
||||
+220
@@ -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
|
||||
{
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+9
-4
@@ -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());
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+7
-2
@@ -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
-3
@@ -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.
|
||||
|
||||
+25
-12
@@ -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);
|
||||
|
||||
|
||||
+71
-27
@@ -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)
|
||||
|
||||
+10
-16
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user