Python: .NET: Executor source gen for workflow executor routing (#3131)

* Roslyn Source Generators for Workflow Executor Routing.

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* WIP.

* All fixed up except dangling sends/yields attriutes, working on that next.

* Add protocol-only generation for SendsMessage/YieldsOutput attributes

* Ensuring collections that can change order are sorted to enable pipeline caching.

* Improvents per PR feedback.

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ben Thomas
2026-01-22 08:02:12 -08:00
committed by GitHub
Unverified
parent 4940d0ef36
commit ea7818d390
25 changed files with 3478 additions and 0 deletions
+1
View File
@@ -143,6 +143,7 @@
<!-- Symbols -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- Toolset -->
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers">
+2
View File
@@ -414,6 +414,7 @@
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/Microsoft.Agents.AI.Workflows.Declarative.AzureAI.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Declarative/Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj" />
<Project Path="src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj" />
<Project Path="src/Microsoft.Agents.AI/Microsoft.Agents.AI.csproj" />
</Folder>
<Folder Name="/Tests/" />
@@ -453,6 +454,7 @@
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.UnitTests/Microsoft.Agents.AI.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,693 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
using Microsoft.Agents.AI.Workflows.Generators.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Microsoft.Agents.AI.Workflows.Generators.Analysis;
/// <summary>
/// Provides semantic analysis of executor route candidates.
/// </summary>
/// <remarks>
/// Analysis is split into two phases for efficiency with incremental generators:
/// <list type="number">
/// <item><see cref="AnalyzeHandlerMethod"/> - Called per method, extracts data and performs method-level validation only.</item>
/// <item><see cref="CombineHandlerMethodResults"/> - Groups methods by class and performs class-level validation once.</item>
/// </list>
/// This avoids redundant class validation when multiple handlers exist in the same class.
/// </remarks>
internal static class SemanticAnalyzer
{
// Fully-qualified type names used for symbol comparison
private const string ExecutorTypeName = "Microsoft.Agents.AI.Workflows.Executor";
private const string WorkflowContextTypeName = "Microsoft.Agents.AI.Workflows.IWorkflowContext";
private const string CancellationTokenTypeName = "System.Threading.CancellationToken";
private const string ValueTaskTypeName = "System.Threading.Tasks.ValueTask";
private const string MessageHandlerAttributeName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
private const string SendsMessageAttributeName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute";
private const string YieldsOutputAttributeName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute";
/// <summary>
/// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName.
/// Returns a MethodAnalysisResult containing both method info and class context.
/// </summary>
/// <remarks>
/// This method only extracts raw data and performs method-level validation.
/// Class-level validation is deferred to <see cref="CombineHandlerMethodResults"/> to avoid
/// redundant validation when a class has multiple handler methods.
/// </remarks>
public static MethodAnalysisResult AnalyzeHandlerMethod(
GeneratorAttributeSyntaxContext context,
CancellationToken cancellationToken)
{
// The target should be a method
if (context.TargetSymbol is not IMethodSymbol methodSymbol)
{
return MethodAnalysisResult.Empty;
}
// Get the containing class
INamedTypeSymbol? classSymbol = methodSymbol.ContainingType;
if (classSymbol is null)
{
return MethodAnalysisResult.Empty;
}
// Get the method syntax for location info
MethodDeclarationSyntax? methodSyntax = context.TargetNode as MethodDeclarationSyntax;
// Extract class-level info (raw facts, no validation here)
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
// Extract class metadata
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
: classSymbol.ContainingNamespace?.ToDisplayString();
string className = classSymbol.Name;
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
bool baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol);
ImmutableEquatableArray<string> classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName);
ImmutableEquatableArray<string> classYieldTypes = GetClassLevelTypes(classSymbol, YieldsOutputAttributeName);
// Get class location for class-level diagnostics
DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken);
// Analyze the handler method (method-level validation only)
// Skip method analysis if class doesn't derive from Executor (class-level diagnostic will be reported later)
var methodDiagnostics = ImmutableArray.CreateBuilder<DiagnosticInfo>();
HandlerInfo? handler = null;
if (derivesFromExecutor)
{
handler = AnalyzeHandler(methodSymbol, methodSyntax, methodDiagnostics);
}
return new MethodAnalysisResult(
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
baseHasConfigureRoutes, classSendTypes, classYieldTypes,
isPartialClass, derivesFromExecutor, hasManualConfigureRoutes,
classLocation,
handler,
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
}
/// <summary>
/// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult.
/// Performs class-level validation once (instead of per-method) for efficiency.
/// </summary>
public static AnalysisResult CombineHandlerMethodResults(IEnumerable<MethodAnalysisResult> methodResults)
{
List<MethodAnalysisResult> methods = methodResults.ToList();
if (methods.Count == 0)
{
return AnalysisResult.Empty;
}
// All methods should have same class info - take from first
MethodAnalysisResult first = methods[0];
Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
// Collect method-level diagnostics
var allDiagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
foreach (var method in methods)
{
foreach (var diag in method.Diagnostics)
{
allDiagnostics.Add(diag.ToRoslynDiagnostic(null));
}
}
// Class-level validation (done once, not per-method)
if (!first.DerivesFromExecutor)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.NotAnExecutor,
classLocation,
first.ClassName,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (!first.IsPartialClass)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ClassMustBePartial,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (first.HasManualConfigureRoutes)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ConfigureRoutesAlreadyDefined,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
// Collect valid handlers
ImmutableArray<HandlerInfo> handlers = methods
.Where(m => m.Handler is not null)
.Select(m => m.Handler!)
.ToImmutableArray();
if (handlers.Length == 0)
{
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
ExecutorInfo executorInfo = new(
first.Namespace,
first.ClassName,
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
first.BaseHasConfigureRoutes,
new ImmutableEquatableArray<HandlerInfo>(handlers),
first.ClassSendTypes,
first.ClassYieldTypes);
if (allDiagnostics.Count > 0)
{
return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
}
return AnalysisResult.Success(executorInfo);
}
/// <summary>
/// Analyzes a class with [SendsMessage] or [YieldsOutput] attribute found by ForAttributeWithMetadataName.
/// Returns ClassProtocolInfo entries for each attribute instance (handles multiple attributes of same type).
/// </summary>
/// <param name="context">The generator attribute syntax context.</param>
/// <param name="attributeKind">Whether this is a Send or Yield attribute.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The analysis results for the class protocol attributes.</returns>
public static ImmutableArray<ClassProtocolInfo> AnalyzeClassProtocolAttribute(
GeneratorAttributeSyntaxContext context,
ProtocolAttributeKind attributeKind,
CancellationToken cancellationToken)
{
// The target should be a class
if (context.TargetSymbol is not INamedTypeSymbol classSymbol)
{
return ImmutableArray<ClassProtocolInfo>.Empty;
}
// Extract class-level info (same for all attributes)
string classKey = GetClassKey(classSymbol);
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
bool hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol);
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
? null
: classSymbol.ContainingNamespace?.ToDisplayString();
string className = classSymbol.Name;
string? genericParameters = GetGenericParameters(classSymbol);
bool isNested = classSymbol.ContainingType != null;
string containingTypeChain = GetContainingTypeChain(classSymbol);
DiagnosticLocationInfo? classLocation = GetClassLocation(classSymbol, cancellationToken);
// Extract a ClassProtocolInfo for each attribute instance
ImmutableArray<ClassProtocolInfo>.Builder results = ImmutableArray.CreateBuilder<ClassProtocolInfo>();
foreach (AttributeData attr in context.Attributes)
{
if (attr.ConstructorArguments.Length > 0 &&
attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
{
string typeName = typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
results.Add(new ClassProtocolInfo(
classKey,
@namespace,
className,
genericParameters,
isNested,
containingTypeChain,
isPartialClass,
derivesFromExecutor,
hasManualConfigureRoutes,
classLocation,
typeName,
attributeKind));
}
}
return results.ToImmutable();
}
/// <summary>
/// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have protocol attributes
/// (no [MessageHandler] methods). This generates only ConfigureSentTypes/ConfigureYieldTypes overrides.
/// </summary>
/// <param name="protocolInfos">The protocol info entries for the class.</param>
/// <returns>The combined analysis result.</returns>
public static AnalysisResult CombineProtocolOnlyResults(IEnumerable<ClassProtocolInfo> protocolInfos)
{
List<ClassProtocolInfo> protocols = protocolInfos.ToList();
if (protocols.Count == 0)
{
return AnalysisResult.Empty;
}
// All entries should have same class info - take from first
ClassProtocolInfo first = protocols[0];
Location classLocation = first.ClassLocation?.ToRoslynLocation() ?? Location.None;
ImmutableArray<Diagnostic>.Builder allDiagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
// Class-level validation
if (!first.DerivesFromExecutor)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.NotAnExecutor,
classLocation,
first.ClassName,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
if (!first.IsPartialClass)
{
allDiagnostics.Add(Diagnostic.Create(
DiagnosticDescriptors.ClassMustBePartial,
classLocation,
first.ClassName));
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
}
// Collect send and yield types
ImmutableArray<string>.Builder sendTypes = ImmutableArray.CreateBuilder<string>();
ImmutableArray<string>.Builder yieldTypes = ImmutableArray.CreateBuilder<string>();
foreach (ClassProtocolInfo protocol in protocols)
{
if (protocol.AttributeKind == ProtocolAttributeKind.Send)
{
sendTypes.Add(protocol.TypeName);
}
else
{
yieldTypes.Add(protocol.TypeName);
}
}
// Sort to ensure consistent ordering for incremental generator caching
sendTypes.Sort(StringComparer.Ordinal);
yieldTypes.Sort(StringComparer.Ordinal);
// Create ExecutorInfo with no handlers but with protocol types
ExecutorInfo executorInfo = new(
first.Namespace,
first.ClassName,
first.GenericParameters,
first.IsNested,
first.ContainingTypeChain,
BaseHasConfigureRoutes: false, // Not relevant for protocol-only
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
if (allDiagnostics.Count > 0)
{
return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable());
}
return AnalysisResult.Success(executorInfo);
}
/// <summary>
/// Gets the source location of the class identifier for diagnostic reporting.
/// </summary>
private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
{
foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
{
SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken);
if (syntax is ClassDeclarationSyntax classDecl)
{
return DiagnosticLocationInfo.FromLocation(classDecl.Identifier.GetLocation());
}
}
return null;
}
/// <summary>
/// Returns a unique identifier for the class used to group methods by their containing type.
/// </summary>
private static string GetClassKey(INamedTypeSymbol classSymbol)
{
return classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
/// <summary>
/// Checks if any declaration of the class has the 'partial' modifier.
/// </summary>
private static bool IsPartialClass(INamedTypeSymbol classSymbol, CancellationToken cancellationToken)
{
foreach (SyntaxReference syntaxRef in classSymbol.DeclaringSyntaxReferences)
{
SyntaxNode syntax = syntaxRef.GetSyntax(cancellationToken);
if (syntax is ClassDeclarationSyntax classDecl &&
classDecl.Modifiers.Any(SyntaxKind.PartialKeyword))
{
return true;
}
}
return false;
}
/// <summary>
/// Walks the inheritance chain to check if the class derives from Executor or Executor&lt;T&gt;.
/// </summary>
private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? current = classSymbol.BaseType;
while (current != null)
{
string fullName = current.OriginalDefinition.ToDisplayString();
if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", StringComparison.Ordinal))
{
return true;
}
current = current.BaseType;
}
return false;
}
/// <summary>
/// Checks if this class directly defines ConfigureRoutes (not inherited).
/// If so, we skip generation to avoid conflicting with user's manual implementation.
/// </summary>
private static bool HasConfigureRoutesDefined(INamedTypeSymbol classSymbol)
{
foreach (var member in classSymbol.GetMembers("ConfigureRoutes"))
{
if (member is IMethodSymbol method && !method.IsAbstract &&
SymbolEqualityComparer.Default.Equals(method.ContainingType, classSymbol))
{
return true;
}
}
return false;
}
/// <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.
/// </summary>
private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol)
{
INamedTypeSymbol? baseType = classSymbol.BaseType;
while (baseType != null)
{
string fullName = baseType.OriginalDefinition.ToDisplayString();
// Stop at Executor - its ConfigureRoutes is abstract/empty
if (fullName == ExecutorTypeName)
{
return false;
}
foreach (var member in baseType.GetMembers("ConfigureRoutes"))
{
if (member is IMethodSymbol method && !method.IsAbstract)
{
return true;
}
}
baseType = baseType.BaseType;
}
return false;
}
/// <summary>
/// Validates a handler method's signature and extracts metadata.
/// </summary>
/// <remarks>
/// Valid signatures:
/// <list type="bullet">
/// <item><c>void Handle(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>ValueTask HandleAsync(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>ValueTask&lt;TResult&gt; HandleAsync(TMessage, IWorkflowContext, [CancellationToken])</c></item>
/// <item><c>TResult Handle(TMessage, IWorkflowContext, [CancellationToken])</c> (sync with result)</item>
/// </list>
/// </remarks>
private static HandlerInfo? AnalyzeHandler(
IMethodSymbol methodSymbol,
MethodDeclarationSyntax? methodSyntax,
ImmutableArray<DiagnosticInfo>.Builder diagnostics)
{
Location location = methodSyntax?.Identifier.GetLocation() ?? Location.None;
// Check if static
if (methodSymbol.IsStatic)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF007", location, methodSymbol.Name));
return null;
}
// Check parameter count
if (methodSymbol.Parameters.Length < 2)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF005", location, methodSymbol.Name));
return null;
}
// Check second parameter is IWorkflowContext
IParameterSymbol secondParam = methodSymbol.Parameters[1];
if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF001", location, methodSymbol.Name));
return null;
}
// Check for optional CancellationToken as third parameter
bool hasCancellationToken = methodSymbol.Parameters.Length >= 3 &&
methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName;
// Analyze return type
ITypeSymbol returnType = methodSymbol.ReturnType;
HandlerSignatureKind? signatureKind = GetSignatureKind(returnType);
if (signatureKind == null)
{
diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name));
return null;
}
// Get input type
ITypeSymbol inputType = methodSymbol.Parameters[0].Type;
string inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Get output type
string? outputTypeName = null;
if (signatureKind == HandlerSignatureKind.ResultSync)
{
outputTypeName = returnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
else if (signatureKind == HandlerSignatureKind.ResultAsync && returnType is INamedTypeSymbol namedReturn)
{
if (namedReturn.TypeArguments.Length == 1)
{
outputTypeName = namedReturn.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
}
}
// Get Yield and Send types from attribute
(ImmutableEquatableArray<string> yieldTypes, ImmutableEquatableArray<string> sendTypes) = GetAttributeTypeArrays(methodSymbol);
return new HandlerInfo(
methodSymbol.Name,
inputTypeName,
outputTypeName,
signatureKind.Value,
hasCancellationToken,
yieldTypes,
sendTypes);
}
/// <summary>
/// Determines the handler signature kind from the return type.
/// </summary>
/// <returns>The signature kind, or null if the return type is not supported (e.g., Task, Task&lt;T&gt;).</returns>
private static HandlerSignatureKind? GetSignatureKind(ITypeSymbol returnType)
{
string returnTypeName = returnType.ToDisplayString();
if (returnType.SpecialType == SpecialType.System_Void)
{
return HandlerSignatureKind.VoidSync;
}
if (returnTypeName == ValueTaskTypeName)
{
return HandlerSignatureKind.VoidAsync;
}
if (returnType is INamedTypeSymbol namedType &&
namedType.OriginalDefinition.ToDisplayString() == "System.Threading.Tasks.ValueTask<TResult>")
{
return HandlerSignatureKind.ResultAsync;
}
// Any non-void, non-Task type is treated as a synchronous result
if (returnType.SpecialType != SpecialType.System_Void &&
!returnTypeName.StartsWith("System.Threading.Tasks.Task", StringComparison.Ordinal) &&
!returnTypeName.StartsWith("System.Threading.Tasks.ValueTask", StringComparison.Ordinal))
{
return HandlerSignatureKind.ResultSync;
}
// Task/Task<T> not supported - must use ValueTask
return null;
}
/// <summary>
/// Extracts Yield and Send type arrays from the [MessageHandler] attribute's named arguments.
/// </summary>
/// <example>
/// [MessageHandler(Yield = new[] { typeof(OutputA), typeof(OutputB) }, Send = new[] { typeof(Request) })]
/// </example>
private static (ImmutableEquatableArray<string> YieldTypes, ImmutableEquatableArray<string> SendTypes) GetAttributeTypeArrays(
IMethodSymbol methodSymbol)
{
var yieldTypes = ImmutableArray<string>.Empty;
var sendTypes = ImmutableArray<string>.Empty;
foreach (var attr in methodSymbol.GetAttributes())
{
if (attr.AttributeClass?.ToDisplayString() != MessageHandlerAttributeName)
{
continue;
}
foreach (var namedArg in attr.NamedArguments)
{
if (namedArg.Key.Equals("Yield", StringComparison.Ordinal) && !namedArg.Value.IsNull)
{
yieldTypes = ExtractTypeArray(namedArg.Value);
}
else if (namedArg.Key.Equals("Send", StringComparison.Ordinal) && !namedArg.Value.IsNull)
{
sendTypes = ExtractTypeArray(namedArg.Value);
}
}
}
return (new ImmutableEquatableArray<string>(yieldTypes), new ImmutableEquatableArray<string>(sendTypes));
}
/// <summary>
/// Converts a TypedConstant array (from attribute argument) to fully-qualified type name strings.
/// </summary>
/// <remarks>
/// Results are sorted to ensure consistent ordering for incremental generator caching.
/// </remarks>
private static ImmutableArray<string> ExtractTypeArray(TypedConstant typedConstant)
{
if (typedConstant.Kind != TypedConstantKind.Array)
{
return ImmutableArray<string>.Empty;
}
ImmutableArray<string>.Builder builder = ImmutableArray.CreateBuilder<string>();
foreach (TypedConstant value in typedConstant.Values)
{
if (value.Value is INamedTypeSymbol typeSymbol)
{
builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
}
// Sort to ensure consistent ordering for incremental generator caching
builder.Sort(StringComparer.Ordinal);
return builder.ToImmutable();
}
/// <summary>
/// Collects types from [SendsMessage] or [YieldsOutput] attributes applied to the class.
/// </summary>
/// <remarks>
/// Results are sorted to ensure consistent ordering for incremental generator caching,
/// since GetAttributes() order is not guaranteed across partial class declarations.
/// </remarks>
/// <example>
/// [SendsMessage(typeof(Request))]
/// [YieldsOutput(typeof(Response))]
/// public partial class MyExecutor : Executor { }
/// </example>
private static ImmutableEquatableArray<string> GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName)
{
ImmutableArray<string>.Builder builder = ImmutableArray.CreateBuilder<string>();
foreach (AttributeData attr in classSymbol.GetAttributes())
{
if (attr.AttributeClass?.ToDisplayString() == attributeName &&
attr.ConstructorArguments.Length > 0 &&
attr.ConstructorArguments[0].Value is INamedTypeSymbol typeSymbol)
{
builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}
}
// Sort to ensure consistent ordering for incremental generator caching
builder.Sort(StringComparer.Ordinal);
return new ImmutableEquatableArray<string>(builder.ToImmutable());
}
/// <summary>
/// Builds the chain of containing types for nested classes, outermost first.
/// </summary>
/// <example>
/// For class Outer.Middle.Inner.MyExecutor, returns "Outer.Middle.Inner"
/// </example>
private static string GetContainingTypeChain(INamedTypeSymbol classSymbol)
{
List<string> chain = new();
INamedTypeSymbol? current = classSymbol.ContainingType;
while (current != null)
{
chain.Insert(0, current.Name);
current = current.ContainingType;
}
return string.Join(".", chain);
}
/// <summary>
/// Returns the generic type parameter clause (e.g., "&lt;T, U&gt;") for generic classes, or null for non-generic.
/// </summary>
private static string? GetGenericParameters(INamedTypeSymbol classSymbol)
{
if (!classSymbol.IsGenericType)
{
return null;
}
string parameters = string.Join(", ", classSymbol.TypeParameters.Select(p => p.Name));
return $"<{parameters}>";
}
}
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
/// <summary>
/// Diagnostic descriptors for the executor route source generator.
/// </summary>
internal static class DiagnosticDescriptors
{
private const string Category = "Microsoft.Agents.AI.Workflows.Generators";
private static readonly Dictionary<string, DiagnosticDescriptor> s_descriptorsById = new();
/// <summary>
/// Gets a diagnostic descriptor by its ID.
/// </summary>
public static DiagnosticDescriptor? GetById(string id)
{
return s_descriptorsById.TryGetValue(id, out var descriptor) ? descriptor : null;
}
private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor)
{
s_descriptorsById[descriptor.Id] = descriptor;
return descriptor;
}
/// <summary>
/// MAFGENWF001: Handler method must have IWorkflowContext parameter.
/// </summary>
public static readonly DiagnosticDescriptor MissingWorkflowContext = Register(new(
id: "MAFGENWF001",
title: "Handler missing IWorkflowContext parameter",
messageFormat: "Method '{0}' marked with [MessageHandler] must have IWorkflowContext as the second parameter",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF002: Handler method has invalid return type.
/// </summary>
public static readonly DiagnosticDescriptor InvalidReturnType = Register(new(
id: "MAFGENWF002",
title: "Handler has invalid return type",
messageFormat: "Method '{0}' marked with [MessageHandler] must return void, ValueTask, or ValueTask<T>",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF003: Executor with [MessageHandler] must be partial.
/// </summary>
public static readonly DiagnosticDescriptor ClassMustBePartial = Register(new(
id: "MAFGENWF003",
title: "Executor with [MessageHandler] must be partial",
messageFormat: "Class '{0}' contains [MessageHandler] methods but is not declared as partial",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF004: [MessageHandler] on non-Executor class.
/// </summary>
public static readonly DiagnosticDescriptor NotAnExecutor = Register(new(
id: "MAFGENWF004",
title: "[MessageHandler] on non-Executor class",
messageFormat: "Method '{0}' is marked with [MessageHandler] but class '{1}' does not derive from Executor",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF005: Handler method has insufficient parameters.
/// </summary>
public static readonly DiagnosticDescriptor InsufficientParameters = Register(new(
id: "MAFGENWF005",
title: "Handler has insufficient parameters",
messageFormat: "Method '{0}' marked with [MessageHandler] must have at least 2 parameters (message and IWorkflowContext)",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF006: ConfigureRoutes already defined.
/// </summary>
public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new(
id: "MAFGENWF006",
title: "ConfigureRoutes already defined",
messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored",
category: Category,
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true));
/// <summary>
/// MAFGENWF007: Handler method is static.
/// </summary>
public static readonly DiagnosticDescriptor HandlerCannotBeStatic = Register(new(
id: "MAFGENWF007",
title: "Handler cannot be static",
messageFormat: "Method '{0}' marked with [MessageHandler] cannot be static",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true));
}
@@ -0,0 +1,18 @@
<Project>
<!-- Import parent Directory.Build.targets if it exists -->
<PropertyGroup>
<_ParentTargetsPath>$([MSBuild]::GetPathOfFileAbove(Directory.Build.targets, $(MSBuildThisFileDirectory)..))</_ParentTargetsPath>
</PropertyGroup>
<Import Project="$(_ParentTargetsPath)" Condition="'$(_ParentTargetsPath)' != ''" />
<!-- Since the generators project must target netstandard2.0, if any other TFM is specified we flag it silently -->
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
<_SkipIncompatibleBuild>true</_SkipIncompatibleBuild>
<!-- Bypass NETSDK1005 by clearing assets file path -->
<ProjectAssetsFile />
<ResolveAssemblyReferencesSilentlySkip>true</ResolveAssemblyReferencesSilentlySkip>
</PropertyGroup>
<!-- Since the generators project must target netstandard2.0, if any other TFM is specified we skip the build. -->
<Import Project="SkipIncompatibleBuild.targets" Condition="'$(_SkipIncompatibleBuild)' == 'true'" />
</Project>
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.Agents.AI.Workflows.Generators.Analysis;
using Microsoft.Agents.AI.Workflows.Generators.Generation;
using Microsoft.Agents.AI.Workflows.Generators.Models;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.Agents.AI.Workflows.Generators;
/// <summary>
/// Roslyn incremental source generator that generates ConfigureRoutes implementations
/// for executor classes with [MessageHandler] attributed methods, and/or ConfigureSentTypes/ConfigureYieldTypes
/// overrides for classes with [SendsMessage]/[YieldsOutput] attributes.
/// </summary>
[Generator]
public sealed class ExecutorRouteGenerator : IIncrementalGenerator
{
private const string MessageHandlerAttributeFullName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute";
private const string SendsMessageAttributeFullName = "Microsoft.Agents.AI.Workflows.SendsMessageAttribute";
private const string YieldsOutputAttributeFullName = "Microsoft.Agents.AI.Workflows.YieldsOutputAttribute";
/// <inheritdoc/>
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Pipeline 1: Methods with [MessageHandler] attribute
IncrementalValuesProvider<MethodAnalysisResult> methodAnalysisResults = context.SyntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: MessageHandlerAttributeFullName,
predicate: static (node, _) => node is MethodDeclarationSyntax,
transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeHandlerMethod(ctx, ct))
.Where(static result => !string.IsNullOrWhiteSpace(result.ClassKey));
// Pipeline 2: Classes with [SendsMessage] attribute
IncrementalValuesProvider<ClassProtocolInfo> sendProtocolResults = context.SyntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: SendsMessageAttributeFullName,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeClassProtocolAttribute(ctx, ProtocolAttributeKind.Send, ct))
.SelectMany(static (results, _) => results);
// Pipeline 3: Classes with [YieldsOutput] attribute
IncrementalValuesProvider<ClassProtocolInfo> yieldProtocolResults = context.SyntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: YieldsOutputAttributeFullName,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeClassProtocolAttribute(ctx, ProtocolAttributeKind.Yield, ct))
.SelectMany(static (results, _) => results);
// Combine all protocol results (Send + Yield)
IncrementalValuesProvider<ClassProtocolInfo> allProtocolResults = sendProtocolResults
.Collect()
.Combine(yieldProtocolResults.Collect())
.SelectMany(static (tuple, _) => tuple.Left.AddRange(tuple.Right));
// Combine all pipelines and produce AnalysisResults grouped by class
IncrementalValuesProvider<AnalysisResult> combinedResults = methodAnalysisResults
.Collect()
.Combine(allProtocolResults.Collect())
.SelectMany(static (tuple, _) => CombineAllResults(tuple.Left, tuple.Right));
// Generate source for valid executors
context.RegisterSourceOutput(
combinedResults.Where(static r => r.ExecutorInfo is not null),
static (ctx, result) =>
{
string source = SourceBuilder.Generate(result.ExecutorInfo!);
string hintName = GetHintName(result.ExecutorInfo!);
ctx.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
});
// Report diagnostics
context.RegisterSourceOutput(
combinedResults.Where(static r => !r.Diagnostics.IsEmpty),
static (ctx, result) =>
{
foreach (Diagnostic diagnostic in result.Diagnostics)
{
ctx.ReportDiagnostic(diagnostic);
}
});
}
/// <summary>
/// Combines method analysis results with class protocol results, grouping by class key.
/// Classes with [MessageHandler] methods get full generation; classes with only protocol
/// attributes get protocol-only generation.
/// </summary>
private static IEnumerable<AnalysisResult> CombineAllResults(
ImmutableArray<MethodAnalysisResult> methodResults,
ImmutableArray<ClassProtocolInfo> protocolResults)
{
// Group method results by class
Dictionary<string, List<MethodAnalysisResult>> methodsByClass = methodResults
.GroupBy(r => r.ClassKey)
.ToDictionary(g => g.Key, g => g.ToList());
// Group protocol results by class
Dictionary<string, List<ClassProtocolInfo>> protocolsByClass = protocolResults
.GroupBy(r => r.ClassKey)
.ToDictionary(g => g.Key, g => g.ToList());
// Track which classes we've processed
HashSet<string> processedClasses = new();
// Process classes that have [MessageHandler] methods
foreach (KeyValuePair<string, List<MethodAnalysisResult>> kvp in methodsByClass)
{
processedClasses.Add(kvp.Key);
yield return SemanticAnalyzer.CombineHandlerMethodResults(kvp.Value);
}
// Process classes that only have protocol attributes (no [MessageHandler] methods)
foreach (KeyValuePair<string, List<ClassProtocolInfo>> kvp in protocolsByClass)
{
if (!processedClasses.Contains(kvp.Key))
{
yield return SemanticAnalyzer.CombineProtocolOnlyResults(kvp.Value);
}
}
}
/// <summary>
/// Generates a hint (virtual file) name for the generated source file based on the ExecutorInfo.
/// </summary>
private static string GetHintName(ExecutorInfo info)
{
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(info.Namespace))
{
sb.Append(info.Namespace)
.Append('.');
}
if (info.IsNested)
{
sb.Append(info.ContainingTypeChain)
.Append('.');
}
sb.Append(info.ClassName);
// Handle generic type parameters in hint name
if (!string.IsNullOrWhiteSpace(info.GenericParameters))
{
// Replace < > with underscores for valid file name
sb.Append('_')
.Append(info.GenericParameters!.Length - 2); // Number of type params approximation
}
sb.Append(".g.cs");
return sb.ToString();
}
}
@@ -0,0 +1,253 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text;
using Microsoft.Agents.AI.Workflows.Generators.Models;
namespace Microsoft.Agents.AI.Workflows.Generators.Generation;
/// <summary>
/// Generates source code for executor route configuration.
/// </summary>
/// <remarks>
/// This builder produces a partial class file that overrides <c>ConfigureRoutes</c> to register
/// handlers discovered via [MessageHandler] attributes. It may also generate <c>ConfigureSentTypes</c>
/// and <c>ConfigureYieldTypes</c> overrides when [SendsMessage] or [YieldsOutput] attributes are present.
/// </remarks>
internal static class SourceBuilder
{
/// <summary>
/// Generates the complete source file for an executor's generated partial class.
/// </summary>
/// <param name="info">The analyzed executor information containing class metadata and handler details.</param>
/// <returns>The generated C# source code as a string.</returns>
public static string Generate(ExecutorInfo info)
{
var sb = new StringBuilder();
// File header
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
// Using directives
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using Microsoft.Agents.AI.Workflows;");
sb.AppendLine();
// Namespace
if (!string.IsNullOrWhiteSpace(info.Namespace))
{
sb.AppendLine($"namespace {info.Namespace};");
sb.AppendLine();
}
// For nested classes, we must emit partial declarations for each containing type.
// Example: if MyExecutor is nested in Outer.Inner, we emit:
// partial class Outer { partial class Inner { partial class MyExecutor { ... } } }
string indent = "";
if (info.IsNested)
{
foreach (string containingType in info.ContainingTypeChain.Split('.'))
{
sb.AppendLine($"{indent}partial class {containingType}");
sb.AppendLine($"{indent}{{");
indent += " ";
}
}
// Class declaration
sb.AppendLine($"{indent}partial class {info.ClassName}{info.GenericParameters}");
sb.AppendLine($"{indent}{{");
string memberIndent = indent + " ";
bool hasContent = false;
// Only generate ConfigureRoutes if there are handlers
if (info.Handlers.Count > 0)
{
GenerateConfigureRoutes(sb, info, memberIndent);
hasContent = true;
}
// 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 (hasContent)
{
sb.AppendLine();
}
GenerateConfigureSentTypes(sb, info, memberIndent);
sb.AppendLine();
GenerateConfigureYieldTypes(sb, info, memberIndent);
}
// Close class
sb.AppendLine($"{indent}}}");
// Close nested classes
if (info.IsNested)
{
string[] containingTypes = info.ContainingTypeChain.Split('.');
for (int i = containingTypes.Length - 1; i >= 0; i--)
{
indent = new string(' ', i * 4);
sb.AppendLine($"{indent}}}");
}
}
return sb.ToString();
}
/// <summary>
/// Generates the ConfigureRoutes override that registers all [MessageHandler] methods.
/// </summary>
private static void GenerateConfigureRoutes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override RouteBuilder 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();
}
// 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.Append($"{bodyIndent} .AddHandler");
AppendHandlerGenericArgs(sb, handler);
sb.AppendLine($"(this.{handler.MethodName});");
}
else
{
// Multiple handlers: chain fluent calls, semicolon only on the last one.
sb.AppendLine($"{bodyIndent}return routeBuilder");
for (int i = 0; i < info.Handlers.Count; i++)
{
HandlerInfo handler = info.Handlers[i];
sb.Append($"{bodyIndent} .AddHandler");
AppendHandlerGenericArgs(sb, handler);
sb.Append($"(this.{handler.MethodName})");
sb.AppendLine();
}
// Remove last newline without using that System.Environment which is banned from use in analyzers
var newLineLength = new StringBuilder().AppendLine().Length;
sb.Remove(sb.Length - newLineLength, newLineLength);
sb.AppendLine(";");
}
sb.AppendLine($"{indent}}}");
}
/// <summary>
/// Appends generic type arguments for AddHandler based on whether the handler returns a value.
/// </summary>
private static void AppendHandlerGenericArgs(StringBuilder sb, HandlerInfo handler)
{
// Handlers returning ValueTask use single type arg; ValueTask<T> uses two.
if (handler.HasOutput && handler.OutputTypeName != null)
{
sb.Append($"<{handler.InputTypeName}, {handler.OutputTypeName}>");
}
else
{
sb.Append($"<{handler.InputTypeName}>");
}
}
/// <summary>
/// Generates ConfigureSentTypes override declaring message types this executor sends via context.SendMessageAsync.
/// </summary>
/// <remarks>
/// Types come from [SendsMessage] attributes on the class or individual handler methods.
/// This enables workflow protocol validation at build time.
/// </remarks>
private static void GenerateConfigureSentTypes(StringBuilder sb, ExecutorInfo info, string indent)
{
sb.AppendLine($"{indent}protected override ISet<Type> ConfigureSentTypes()");
sb.AppendLine($"{indent}{{");
string bodyIndent = indent + " ";
sb.AppendLine($"{bodyIndent}var types = base.ConfigureSentTypes();");
foreach (var type in info.ClassSendTypes)
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.SendTypes)
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
}
sb.AppendLine($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
/// <summary>
/// Generates ConfigureYieldTypes override declaring message types this executor yields via context.YieldOutputAsync.
/// </summary>
/// <remarks>
/// Types come from [YieldsOutput] attributes and handler return types (ValueTask&lt;T&gt;).
/// This enables workflow protocol validation at build time.
/// </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)
{
if (addedTypes.Add(type))
{
sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));");
}
}
foreach (var handler in info.Handlers)
{
foreach (var type in handler.YieldTypes)
{
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($"{bodyIndent}return types;");
sb.AppendLine($"{indent}}}");
}
}
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Source generators MUST target netstandard2.0 only -->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<!-- Enable C# 9 records support on netstandard2.0 -->
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<!-- Source generator specific settings -->
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<!-- Don't include build output in lib folder -->
<IncludeBuildOutput>false</IncludeBuildOutput>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<!-- Suppress nullable warnings for netstandard2.0 -->
<NoWarn>$(NoWarn);nullable</NoWarn>
<!-- Suppress analyzer release tracking requirement for source generators -->
<NoWarn>$(NoWarn);RS2008</NoWarn>
<!-- Suppress NU5128 warning about dependencies not matching target framework -->
<NoWarn>$(NoWarn);NU5128</NoWarn>
</PropertyGroup>
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework Workflows Source Generators</Title>
<Description>Provides Roslyn source generators for Microsoft Agent Framework Workflows, enabling compile-time route configuration for executors.</Description>
<DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>
<ItemGroup>
<!-- Use Roslyn 4.4.0 - minimum version for ForAttributeWithMetadataName API.
Corresponds to .NET 7 SDK / VS 2022 17.4+.
Higher versions would require newer SDKs, breaking users on older versions.
See: https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/ -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" VersionOverride="4.4.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" VersionOverride="3.3.4" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Include the analyzer DLL using the correct target framework path -->
<None Include="$(OutputPath)$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="$(OutputPath)$(AssemblyName).pdb" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<!-- Ensure the files exist before packing -->
<Target Name="EnsureAnalyzerAssembliesExist" BeforeTargets="GenerateNuspec">
<Error Condition="!Exists('$(OutputPath)$(AssemblyName).dll')"
Text="Analyzer assembly not found at: $(OutputPath)$(AssemblyName).dll" />
</Target>
</Project>
@@ -0,0 +1,50 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents the result of analyzing a class with [MessageHandler] attributed methods.
/// Combines the executor info (if valid) with any diagnostics to report.
/// Note: Instances of this class should not be used within the analyzers caching
/// layer because it directly contains a collection of <see cref="Diagnostic"/> objects.
/// </summary>
/// <param name="executorInfo">The executor information.</param>
/// <param name="diagnostics">Any diagnostics to report.</param>
internal sealed class AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray<Diagnostic> diagnostics)
{
/// <summary>
/// Gets the executor information.
/// </summary>
public ExecutorInfo? ExecutorInfo { get; } = executorInfo;
/// <summary>
/// Gets the diagnostics to report.
/// </summary>
public ImmutableArray<Diagnostic> Diagnostics { get; } = diagnostics.IsDefault ? ImmutableArray<Diagnostic>.Empty : diagnostics;
/// <summary>
/// Creates a successful result with executor info and no diagnostics.
/// </summary>
public static AnalysisResult Success(ExecutorInfo info) =>
new(info, ImmutableArray<Diagnostic>.Empty);
/// <summary>
/// Creates a result with only diagnostics (no valid executor info).
/// </summary>
public static AnalysisResult WithDiagnostics(ImmutableArray<Diagnostic> diagnostics) =>
new(null, diagnostics);
/// <summary>
/// Creates a result with executor info and diagnostics.
/// </summary>
public static AnalysisResult WithInfoAndDiagnostics(ExecutorInfo info, ImmutableArray<Diagnostic> diagnostics) =>
new(info, diagnostics);
/// <summary>
/// Creates an empty result (no info, no diagnostics).
/// </summary>
public static AnalysisResult Empty => new(null, ImmutableArray<Diagnostic>.Empty);
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes.
/// Used by the incremental generator pipeline to capture classes that declare protocol types
/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented).
/// </summary>
/// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param>
/// <param name="Namespace">The namespace of the class.</param>
/// <param name="ClassName">The name of the class.</param>
/// <param name="GenericParameters">The generic type parameters (e.g., "&lt;T&gt;"), or null if not generic.</param>
/// <param name="IsNested">Whether the class is nested inside another class.</param>
/// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param>
/// <param name="IsPartialClass">Whether the class is declared as partial.</param>
/// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param>
/// <param name="HasManualConfigureRoutes">Whether the class has a manually defined ConfigureRoutes method.</param>
/// <param name="ClassLocation">Location info for diagnostics.</param>
/// <param name="TypeName">The fully qualified type name from the attribute.</param>
/// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param>
internal sealed record ClassProtocolInfo(
string ClassKey,
string? Namespace,
string ClassName,
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool IsPartialClass,
bool DerivesFromExecutor,
bool HasManualConfigureRoutes,
DiagnosticLocationInfo? ClassLocation,
string TypeName,
ProtocolAttributeKind AttributeKind)
{
/// <summary>
/// Gets an empty result for invalid targets.
/// </summary>
public static ClassProtocolInfo Empty { get; } = new(
string.Empty, null, string.Empty, null, false, string.Empty,
false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
}
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Generators.Diagnostics;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents diagnostic information in a form that supports value equality.
/// Location is stored as file path + span, which can be used to recreate a Location.
/// </summary>
internal sealed record DiagnosticInfo(
string DiagnosticId,
string FilePath,
TextSpan Span,
LinePositionSpan LineSpan,
ImmutableEquatableArray<string> MessageArgs)
{
/// <summary>
/// Creates a DiagnosticInfo from a location and message arguments.
/// </summary>
public static DiagnosticInfo Create(string diagnosticId, Location location, params string[] messageArgs)
{
FileLinePositionSpan lineSpan = location.GetLineSpan();
return new DiagnosticInfo(
diagnosticId,
lineSpan.Path ?? string.Empty,
location.SourceSpan,
lineSpan.Span,
new ImmutableEquatableArray<string>(System.Collections.Immutable.ImmutableArray.Create(messageArgs)));
}
/// <summary>
/// Converts this info back to a Roslyn Diagnostic.
/// </summary>
public Diagnostic ToRoslynDiagnostic(SyntaxTree? syntaxTree)
{
DiagnosticDescriptor? descriptor = DiagnosticDescriptors.GetById(this.DiagnosticId);
if (descriptor is null)
{
// Fallback - should not happen
object[] fallbackArgs = new object[this.MessageArgs.Count];
for (int i = 0; i < this.MessageArgs.Count; i++)
{
fallbackArgs[i] = this.MessageArgs[i];
}
return Diagnostic.Create(
DiagnosticDescriptors.InsufficientParameters,
Location.None,
fallbackArgs);
}
Location location;
if (syntaxTree is not null)
{
location = Location.Create(syntaxTree, this.Span);
}
else if (!string.IsNullOrWhiteSpace(this.FilePath))
{
location = Location.Create(this.FilePath, this.Span, this.LineSpan);
}
else
{
location = Location.None;
}
object[] args = new object[this.MessageArgs.Count];
for (int i = 0; i < this.MessageArgs.Count; i++)
{
args[i] = this.MessageArgs[i];
}
return Diagnostic.Create(descriptor, location, args);
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents location information in a form that supports value equality making it friendly for source gen caching.
/// </summary>
internal sealed record DiagnosticLocationInfo(
string FilePath,
TextSpan Span,
LinePositionSpan LineSpan)
{
/// <summary>
/// Creates a DiagnosticLocationInfo from a Roslyn Location.
/// </summary>
public static DiagnosticLocationInfo? FromLocation(Location? location)
{
if (location is null || location == Location.None)
{
return null;
}
FileLinePositionSpan lineSpan = location.GetLineSpan();
return new DiagnosticLocationInfo(
lineSpan.Path ?? string.Empty,
location.SourceSpan,
lineSpan.Span);
}
/// <summary>
/// Converts back to a Roslyn Location.
/// </summary>
public Location ToRoslynLocation()
{
if (string.IsNullOrWhiteSpace(this.FilePath))
{
return Location.None;
}
return Location.Create(this.FilePath, this.Span, this.LineSpan);
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Contains all information needed to generate code for an executor class.
/// Uses record for automatic value equality, which is required for incremental generator caching.
/// </summary>
/// <param name="Namespace">The namespace of the executor class.</param>
/// <param name="ClassName">The name of the executor class.</param>
/// <param name="GenericParameters">The generic type parameters of the class (e.g., "&lt;T, U&gt;"), or null if not generic.</param>
/// <param name="IsNested">Whether the class is nested inside another class.</param>
/// <param name="ContainingTypeChain">The chain of containing types for nested classes (e.g., "OuterClass.InnerClass"). Empty string if not nested.</param>
/// <param name="BaseHasConfigureRoutes">Whether the base class has a ConfigureRoutes method that should be called.</param>
/// <param name="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>
internal sealed record ExecutorInfo(
string? Namespace,
string ClassName,
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
ImmutableEquatableArray<HandlerInfo> Handlers,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes)
{
/// <summary>
/// Gets whether any protocol type overrides should be generated.
/// </summary>
public bool ShouldGenerateProtocolOverrides =>
!this.ClassSendTypes.IsEmpty ||
!this.ClassYieldTypes.IsEmpty ||
this.HasHandlerWithSendTypes ||
this.HasHandlerWithYieldTypes;
/// <summary>
/// Gets whether any handler has explicit Send types.
/// </summary>
public bool HasHandlerWithSendTypes
{
get
{
foreach (var handler in this.Handlers)
{
if (!handler.SendTypes.IsEmpty)
{
return true;
}
}
return false;
}
}
/// <summary>
/// Gets whether any handler has explicit Yield types or output types.
/// </summary>
public bool HasHandlerWithYieldTypes
{
get
{
foreach (var handler in this.Handlers)
{
if (!handler.YieldTypes.IsEmpty)
{
return true;
}
if (handler.HasOutput)
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents the signature kind of a message handler method.
/// </summary>
internal enum HandlerSignatureKind
{
/// <summary>Void synchronous: void Handler(T, IWorkflowContext) or void Handler(T, IWorkflowContext, CT)</summary>
VoidSync,
/// <summary>Void asynchronous: ValueTask Handler(T, IWorkflowContext[, CT])</summary>
VoidAsync,
/// <summary>Result synchronous: TResult Handler(T, IWorkflowContext[, CT])</summary>
ResultSync,
/// <summary>Result asynchronous: ValueTask&lt;TResult&gt; Handler(T, IWorkflowContext[, CT])</summary>
ResultAsync
}
/// <summary>
/// Contains information about a single message handler method.
/// Uses record for automatic value equality, which is required for incremental generator caching.
/// </summary>
/// <param name="MethodName">The name of the handler method.</param>
/// <param name="InputTypeName">The fully-qualified type name of the input message type.</param>
/// <param name="OutputTypeName">The fully-qualified type name of the output type, or null if the handler is void.</param>
/// <param name="SignatureKind">The signature kind of the handler.</param>
/// <param name="HasCancellationToken">Whether the handler method has a CancellationToken parameter.</param>
/// <param name="YieldTypes">The types explicitly declared in the Yield property of [MessageHandler].</param>
/// <param name="SendTypes">The types explicitly declared in the Send property of [MessageHandler].</param>
internal sealed record HandlerInfo(
string MethodName,
string InputTypeName,
string? OutputTypeName,
HandlerSignatureKind SignatureKind,
bool HasCancellationToken,
ImmutableEquatableArray<string> YieldTypes,
ImmutableEquatableArray<string> SendTypes)
{
/// <summary>
/// Gets whether this handler returns a value (either sync or async).
/// </summary>
public bool HasOutput => this.SignatureKind == HandlerSignatureKind.ResultSync || this.SignatureKind == HandlerSignatureKind.ResultAsync;
}
@@ -0,0 +1,125 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Provides an immutable list implementation which implements sequence equality.
/// Copied from: https://github.com/dotnet/runtime/blob/main/src/libraries/Common/src/SourceGenerators/ImmutableEquatableArray.cs
/// </summary>
internal sealed class ImmutableEquatableArray<T> : IEquatable<ImmutableEquatableArray<T>>, IReadOnlyList<T>
where T : IEquatable<T>
{
/// <summary>
/// Creates a new empty <see cref="ImmutableEquatableArray{T}"/>.
/// </summary>
public static ImmutableEquatableArray<T> Empty { get; } = new ImmutableEquatableArray<T>(Array.Empty<T>());
private readonly T[] _values;
/// <summary>
/// Gets the element at the specified index.
/// </summary>
/// <param name="index"></param>
/// <returns></returns>
public T this[int index] => this._values[index];
/// <summary>
/// Gets the number of elements contained in the collection.
/// </summary>
public int Count => this._values.Length;
/// <summary>
/// Gets whether the array is empty.
/// </summary>
public bool IsEmpty => this._values.Length == 0;
/// <summary>
/// Initializes a new instance of the ImmutableEquatableArray{T} class that contains the elements from the specified
/// collection.
/// </summary>
/// <remarks>The elements from the provided collection are copied into the immutable array. Subsequent
/// changes to the original collection do not affect the contents of this array.</remarks>
/// <param name="values">The collection of elements to initialize the array with. Cannot be null.</param>
public ImmutableEquatableArray(IEnumerable<T> values) => this._values = values.ToArray();
/// <inheritdoc/>
public bool Equals(ImmutableEquatableArray<T>? other) => other != null && ((ReadOnlySpan<T>)this._values).SequenceEqual(other._values);
/// <inheritdoc/>
public override bool Equals(object? obj)
=> obj is ImmutableEquatableArray<T> other && this.Equals(other);
/// <inheritdoc/>
public override int GetHashCode()
{
int hash = 0;
foreach (T value in this._values)
{
hash = HashHelpers.Combine(hash, value is null ? 0 : value.GetHashCode());
}
return hash;
}
/// <inheritdoc/>
public Enumerator GetEnumerator() => new(this._values);
IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)this._values).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => this._values.GetEnumerator();
/// <inheritdoc/>
public struct Enumerator
{
private readonly T[] _values;
private int _index;
internal Enumerator(T[] values)
{
this._values = values;
this._index = -1;
}
/// <inheritdoc/>
public bool MoveNext()
{
int newIndex = this._index + 1;
if ((uint)newIndex < (uint)this._values.Length)
{
this._index = newIndex;
return true;
}
return false;
}
/// <summary>
/// The element at the current position of the enumerator.
/// </summary>
public readonly T Current => this._values[this._index];
}
}
internal static class ImmutableEquatableArray
{
public static ImmutableEquatableArray<T> ToImmutableEquatableArray<T>(this IEnumerable<T> values) where T : IEquatable<T>
=> new(values);
}
// Copied from https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Numerics/Hashing/HashHelpers.cs#L6
internal static class HashHelpers
{
public static int Combine(int h1, int h2)
{
// RyuJIT optimizes this to use the ROL instruction
// Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830
uint rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27);
return ((int)rol5 + h1) ^ h2;
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Represents the result of analyzing a single method with [MessageHandler].
/// Contains both the method's handler info and class context for grouping.
/// Uses value-equatable types to support incremental generator caching.
/// </summary>
/// <remarks>
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes)
/// is extracted here but validated once per class in CombineMethodResults to avoid
/// redundant validation work when a class has multiple handlers.
/// </remarks>
internal sealed record MethodAnalysisResult(
// Class identification for grouping
string ClassKey,
// Class-level info (extracted once per method, will be same for all methods in class)
string? Namespace,
string ClassName,
string? GenericParameters,
bool IsNested,
string ContainingTypeChain,
bool BaseHasConfigureRoutes,
ImmutableEquatableArray<string> ClassSendTypes,
ImmutableEquatableArray<string> ClassYieldTypes,
// Class-level facts (used for validation in CombineMethodResults)
bool IsPartialClass,
bool DerivesFromExecutor,
bool HasManualConfigureRoutes,
// Class location for diagnostics (value-equatable)
DiagnosticLocationInfo? ClassLocation,
// Method-level info (null if method validation failed)
HandlerInfo? Handler,
// Method-level diagnostics only (class-level diagnostics created in CombineMethodResults)
ImmutableEquatableArray<DiagnosticInfo> Diagnostics)
{
/// <summary>
/// Gets an empty result for invalid targets (e.g., attribute on non-method).
/// </summary>
public static MethodAnalysisResult Empty { get; } = new(
string.Empty, null, string.Empty, null, false, string.Empty,
false, ImmutableEquatableArray<string>.Empty, ImmutableEquatableArray<string>.Empty,
false, false, false,
null, null, ImmutableEquatableArray<DiagnosticInfo>.Empty);
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows.Generators.Models;
/// <summary>
/// Identifies the kind of protocol attribute.
/// </summary>
internal enum ProtocolAttributeKind
{
/// <summary>
/// The [SendsMessage] attribute.
/// </summary>
Send,
/// <summary>
/// The [YieldsOutput] attribute.
/// </summary>
Yield
}
@@ -0,0 +1,10 @@
<!-- Targets to skip build when incompatible TFM is passed -->
<Project>
<Target Name="CoreCompile">
<Message Importance="high" Text="Skipping $(MSBuildProjectName) - TFM $(TargetFramework) is not supported (requires netstandard2.0)" />
</Target>
<Target Name="CreateManifestResourceNames" />
<Target Name="CopyFilesToOutputDirectory" />
</Project>
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Marks a method as a message handler for source-generated route configuration.
/// The method signature determines the input type and optional output type.
/// </summary>
/// <remarks>
/// <para>
/// Methods marked with this attribute must have a signature matching one of the following patterns:
/// <list type="bullet">
/// <item><c>void Handler(TMessage, IWorkflowContext)</c></item>
/// <item><c>void Handler(TMessage, IWorkflowContext, CancellationToken)</c></item>
/// <item><c>ValueTask Handler(TMessage, IWorkflowContext)</c></item>
/// <item><c>ValueTask Handler(TMessage, IWorkflowContext, CancellationToken)</c></item>
/// <item><c>TResult Handler(TMessage, IWorkflowContext)</c></item>
/// <item><c>TResult Handler(TMessage, IWorkflowContext, CancellationToken)</c></item>
/// <item><c>ValueTask&lt;TResult&gt; Handler(TMessage, IWorkflowContext)</c></item>
/// <item><c>ValueTask&lt;TResult&gt; Handler(TMessage, IWorkflowContext, CancellationToken)</c></item>
/// </list>
/// </para>
/// <para>
/// The containing class must be <c>partial</c> and derive from <see cref="Executor"/>.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public partial class MyExecutor : Executor
/// {
/// [MessageHandler]
/// private async ValueTask&lt;MyResponse&gt; HandleQueryAsync(
/// MyQuery query, IWorkflowContext ctx, CancellationToken ct)
/// {
/// return new MyResponse();
/// }
///
/// [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])]
/// private void HandleStream(StreamRequest req, IWorkflowContext ctx)
/// {
/// // Handler with explicit yield and send types
/// }
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public sealed class MessageHandlerAttribute : Attribute
{
/// <summary>
/// Gets or sets the types that this handler may yield as workflow outputs.
/// </summary>
/// <remarks>
/// If not specified, the return type (if any) is used as the default yield type.
/// Use this property to explicitly declare additional output types or to override
/// the default inference from the return type.
/// </remarks>
public Type[]? Yield { get; set; }
/// <summary>
/// Gets or sets the types that this handler may send as messages to other executors.
/// </summary>
/// <remarks>
/// Use this property to declare the message types that this handler may send
/// via <see cref="IWorkflowContext.SendMessageAsync"/> during its execution.
/// This information is used for protocol validation and documentation.
/// </remarks>
public Type[]? Send { get; set; }
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Declares that an executor may send messages of the specified type.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to an <see cref="Executor"/> class to declare the types of messages
/// it may send via <see cref="IWorkflowContext.SendMessageAsync"/>. This information is used
/// for protocol validation and documentation.
/// </para>
/// <para>
/// This attribute can be applied multiple times to declare multiple message types.
/// It is inherited by derived classes, allowing base executors to declare common message types.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [SendsMessage(typeof(PollToken))]
/// [SendsMessage(typeof(StatusUpdate))]
/// public partial class MyExecutor : Executor
/// {
/// // ...
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class SendsMessageAttribute : Attribute
{
/// <summary>
/// Gets the type of message that the executor may send.
/// </summary>
public Type Type { get; }
/// <summary>
/// Initializes a new instance of the <see cref="SendsMessageAttribute"/> class.
/// </summary>
/// <param name="type">The type of message that the executor may send.</param>
/// <exception cref="ArgumentNullException"><paramref name="type"/> is <see langword="null"/>.</exception>
public SendsMessageAttribute(Type type)
{
this.Type = Throw.IfNull(type);
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Declares that an executor may yield messages of the specified type as workflow outputs.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to an <see cref="Executor"/> class to declare the types of messages
/// it may yield via <see cref="IWorkflowContext.YieldOutputAsync"/>. This information is used
/// for protocol validation and documentation.
/// </para>
/// <para>
/// This attribute can be applied multiple times to declare multiple output types.
/// It is inherited by derived classes, allowing base executors to declare common output types.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// [YieldsOutput(typeof(FinalResult))]
/// [YieldsOutput(typeof(StreamChunk))]
/// public partial class MyExecutor : Executor
/// {
/// // ...
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
public sealed class YieldsOutputAttribute : Attribute
{
/// <summary>
/// Gets the type of message that the executor may yield.
/// </summary>
public Type Type { get; }
/// <summary>
/// Initializes a new instance of the <see cref="YieldsOutputAttribute"/> class.
/// </summary>
/// <param name="type">The type of message that the executor may yield.</param>
/// <exception cref="ArgumentNullException"><paramref name="type"/> is <see langword="null"/>.</exception>
public YieldsOutputAttribute(Type type)
{
this.Type = Throw.IfNull(type);
}
}
@@ -25,6 +25,15 @@
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.UnitTests" />
<InternalsVisibleTo Include="Microsoft.Agents.AI.Workflows.Generators.UnitTests" />
</ItemGroup>
<!-- Include source generator -->
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false"
GlobalPropertiesToRemove="TargetFramework" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,145 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Microsoft.Agents.AI.Workflows.Generators.UnitTests;
/// <summary>
/// Helper class for testing the ExecutorRouteGenerator.
/// </summary>
public static class GeneratorTestHelper
{
/// <summary>
/// Runs the ExecutorRouteGenerator on the provided source code and returns the result.
/// </summary>
public static GeneratorRunResult RunGenerator(string source) => RunGenerator([source]);
/// <summary>
/// Runs the ExecutorRouteGenerator on multiple source files and returns the result.
/// Use this to test scenarios with partial classes split across files.
/// </summary>
public static GeneratorRunResult RunGenerator(params string[] sources)
{
var syntaxTrees = sources.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray();
var references = GetMetadataReferences();
var compilation = CSharpCompilation.Create(
assemblyName: "TestAssembly",
syntaxTrees: syntaxTrees,
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var generator = new ExecutorRouteGenerator();
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics);
var runResult = driver.GetRunResult();
return new GeneratorRunResult(
runResult,
outputCompilation,
diagnostics);
}
/// <summary>
/// Runs the generator and asserts that it produces exactly one generated file with the expected content.
/// </summary>
public static void AssertGeneratesSource(string source, string expectedGeneratedSource)
{
var result = RunGenerator(source);
result.RunResult.GeneratedTrees.Should().HaveCount(1, "expected exactly one generated file");
var generatedSource = result.RunResult.GeneratedTrees[0].ToString();
generatedSource.Should().Contain(expectedGeneratedSource);
}
/// <summary>
/// Runs the generator and asserts that no source is generated.
/// </summary>
public static void AssertGeneratesNoSource(string source)
{
var result = RunGenerator(source);
result.RunResult.GeneratedTrees.Should().BeEmpty("expected no generated files");
}
/// <summary>
/// Runs the generator and asserts that a specific diagnostic is produced.
/// </summary>
public static void AssertProducesDiagnostic(string source, string diagnosticId)
{
var result = RunGenerator(source);
var generatorDiagnostics = result.RunResult.Diagnostics;
generatorDiagnostics.Should().Contain(d => d.Id == diagnosticId,
$"expected diagnostic {diagnosticId} to be produced");
}
/// <summary>
/// Runs the generator and asserts that compilation succeeds with no errors.
/// </summary>
public static void AssertCompilationSucceeds(string source)
{
var result = RunGenerator(source);
var errors = result.OutputCompilation.GetDiagnostics()
.Where(d => d.Severity == DiagnosticSeverity.Error)
.ToList();
errors.Should().BeEmpty("compilation should succeed without errors");
}
private static ImmutableArray<MetadataReference> GetMetadataReferences()
{
var assemblies = new[]
{
typeof(object).Assembly, // System.Runtime
typeof(Attribute).Assembly, // System.Runtime
typeof(ValueTask).Assembly, // System.Threading.Tasks.Extensions
typeof(CancellationToken).Assembly, // System.Threading
typeof(ISet<>).Assembly, // System.Collections
typeof(Executor).Assembly, // Microsoft.Agents.AI.Workflows
};
var references = new List<MetadataReference>();
foreach (var assembly in assemblies)
{
references.Add(MetadataReference.CreateFromFile(assembly.Location));
}
// Add netstandard reference
var netstandardAssembly = Assembly.Load("netstandard, Version=2.0.0.0");
references.Add(MetadataReference.CreateFromFile(netstandardAssembly.Location));
// Add System.Runtime reference for core types
var runtimeAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
var systemRuntimePath = Path.Combine(runtimeAssemblyPath, "System.Runtime.dll");
if (File.Exists(systemRuntimePath))
{
references.Add(MetadataReference.CreateFromFile(systemRuntimePath));
}
return [.. references.Distinct()];
}
}
/// <summary>
/// Contains the results of running the generator.
/// </summary>
public record GeneratorRunResult(
GeneratorDriverRunResult RunResult,
Compilation OutputCompilation,
ImmutableArray<Diagnostic> Diagnostics);
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Generator tests need to reference Roslyn which requires newer TFMs -->
<TargetFrameworks>net10.0</TargetFrameworks>
<!-- Suppress "mark as const" warnings for test source strings -->
<NoWarn>$(NoWarn);RCS1118</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<!-- Generator builds as netstandard2.0, reference it with special handling -->
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Workflows.Generators\Microsoft.Agents.AI.Workflows.Generators.csproj"
PrivateAssets="all"
GlobalPropertiesToRemove="TargetFramework" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>
</Project>