From ae7dbaa0dfc7f44f6ddae6ab8e2a8858ed0cfdb8 Mon Sep 17 00:00:00 2001 From: alliscode Date: Wed, 7 Jan 2026 13:51:13 -0800 Subject: [PATCH 01/13] Initial working version with tests. --- dotnet/Directory.Packages.props | 1 + dotnet/agent-framework-dotnet.slnx | 1 + .../Concurrent/Concurrent/Program.cs | 7 +- .../Workflows/Directory.Build.props | 13 + .../Analysis/SemanticAnalyzer.cs | 458 +++++++++++++++ .../Diagnostics/DiagnosticDescriptors.cs | 107 ++++ .../ExecutorRouteGenerator.cs | 100 ++++ .../Generation/SourceBuilder.cs | 221 +++++++ ...soft.Agents.AI.Workflows.Generators.csproj | 48 ++ .../Models/AnalysisResult.cs | 47 ++ .../Models/DiagnosticInfo.cs | 77 +++ .../Models/EquatableArray.cs | 126 ++++ .../Models/ExecutorInfo.cs | 80 +++ .../Models/HandlerInfo.cs | 47 ++ .../Models/MethodAnalysisResult.cs | 33 ++ .../Attributes/MessageHandlerAttribute.cs | 70 +++ .../Attributes/SendsMessageAttribute.cs | 49 ++ .../Attributes/YieldsMessageAttribute.cs | 49 ++ .../Microsoft.Agents.AI.Workflows/Executor.cs | 2 + .../Microsoft.Agents.AI.Workflows.csproj | 9 + .../Reflection/IMessageHandler.cs | 13 + .../Reflection/MessageHandlerInfo.cs | 2 + .../Reflection/ReflectingExecutor.cs | 7 + .../Reflection/RouteBuilderExtensions.cs | 2 + .../StatefulExecutor.cs | 2 + .../ExecutorRouteGeneratorTests.cs | 554 ++++++++++++++++++ .../GeneratorTestHelper.cs | 139 +++++ ...s.AI.Workflows.Generators.UnitTests.csproj | 23 + .../ReflectionSmokeTest.cs | 2 + .../Sample/01_Simple_Workflow_Sequential.cs | 2 + .../Sample/02_Simple_Workflow_Condition.cs | 2 + .../Sample/03_Simple_Workflow_Loop.cs | 2 + dotnet/wf-code-gen-impact.md | 257 ++++++++ dotnet/wf-source-gen-bp.md | 439 ++++++++++++++ dotnet/wf-source-gen-changes.md | 258 ++++++++ wf-source-gen-plan.md | 293 +++++++++ 36 files changed, 3539 insertions(+), 3 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Directory.Build.props create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj create mode 100644 dotnet/wf-code-gen-impact.md create mode 100644 dotnet/wf-source-gen-bp.md create mode 100644 dotnet/wf-source-gen-changes.md create mode 100644 wf-source-gen-plan.md diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 9202b36f2a..b292f02eb1 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -143,6 +143,7 @@ + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 002efdbab1..01693a29ce 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -396,6 +396,7 @@ + diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index c839149d6c..e5373554c3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -72,8 +72,8 @@ private static async Task Main() /// /// Executor that starts the concurrent processing by sending messages to the agents. /// -internal sealed class ConcurrentStartExecutor() : - Executor("ConcurrentStartExecutor") +internal sealed partial class ConcurrentStartExecutor() : + Executor("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. @@ -83,7 +83,8 @@ internal sealed class ConcurrentStartExecutor() : /// The to monitor for cancellation requests. /// The default is . /// A task representing the asynchronous operation - public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + [MessageHandler] + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. diff --git a/dotnet/samples/GettingStarted/Workflows/Directory.Build.props b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props new file mode 100644 index 0000000000..8ad5839332 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs new file mode 100644 index 0000000000..aea56b4503 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -0,0 +1,458 @@ +// 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; + +/// +/// Provides semantic analysis of executor route candidates. +/// +internal static class SemanticAnalyzer +{ + 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 YieldsMessageAttributeName = "Microsoft.Agents.AI.Workflows.YieldsMessageAttribute"; + + /// + /// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName. + /// Returns a MethodAnalysisResult containing both method info and class context. + /// + public static MethodAnalysisResult AnalyzeMethod( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken) + { + var diagnostics = ImmutableArray.CreateBuilder(); + + // The target should be a method + if (context.TargetSymbol is not IMethodSymbol methodSymbol) + { + return CreateEmptyResult(); + } + + // Get the containing class + var classSymbol = methodSymbol.ContainingType; + if (classSymbol is null) + { + return CreateEmptyResult(); + } + + // Get the method syntax for location info + var methodSyntax = context.TargetNode as MethodDeclarationSyntax; + + // Extract class-level info + var classKey = GetClassKey(classSymbol); + var isPartialClass = IsPartialClass(classSymbol, cancellationToken); + var derivesFromExecutor = DerivesFromExecutor(classSymbol); + var hasManualConfigureRoutes = HasConfigureRoutesDefined(classSymbol); + + // Extract class metadata + var @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true + ? null + : classSymbol.ContainingNamespace?.ToDisplayString(); + var className = classSymbol.Name; + var genericParameters = GetGenericParameters(classSymbol); + var isNested = classSymbol.ContainingType != null; + var containingTypeChain = GetContainingTypeChain(classSymbol); + var baseHasConfigureRoutes = BaseHasConfigureRoutes(classSymbol); + var classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName); + var classYieldTypes = GetClassLevelTypes(classSymbol, YieldsMessageAttributeName); + + // Validate class-level requirements and collect diagnostics + if (!derivesFromExecutor) + { + diagnostics.Add(DiagnosticInfo.Create( + "WFGEN004", + methodSyntax?.Identifier.GetLocation() ?? context.TargetNode.GetLocation(), + methodSymbol.Name, + classSymbol.Name)); + + return new MethodAnalysisResult( + classKey, @namespace, className, genericParameters, isNested, containingTypeChain, + baseHasConfigureRoutes, classSendTypes, classYieldTypes, + isPartialClass, derivesFromExecutor, hasManualConfigureRoutes, + Handler: null, + Diagnostics: new EquatableArray(diagnostics.ToImmutable())); + } + + // Analyze the handler method + var handler = AnalyzeHandler(methodSymbol, methodSyntax, diagnostics); + + return new MethodAnalysisResult( + classKey, @namespace, className, genericParameters, isNested, containingTypeChain, + baseHasConfigureRoutes, classSendTypes, classYieldTypes, + isPartialClass, derivesFromExecutor, hasManualConfigureRoutes, + handler, + Diagnostics: new EquatableArray(diagnostics.ToImmutable())); + } + + /// + /// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult. + /// + public static AnalysisResult CombineMethodResults(IEnumerable methodResults) + { + var methods = methodResults.ToList(); + if (methods.Count == 0) + { + return AnalysisResult.Empty; + } + + // All methods should have same class info - take from first + var first = methods[0]; + + // Combine all diagnostics + var allDiagnostics = methods + .SelectMany(m => m.Diagnostics) + .ToImmutableArray(); + + // Check class-level validation + if (!first.DerivesFromExecutor) + { + // Diagnostics already added per-method + return AnalysisResult.WithDiagnostics( + allDiagnostics.Select(d => d.ToDiagnostic(null)).ToImmutableArray()); + } + + if (!first.IsPartialClass) + { + var diag = Diagnostic.Create( + DiagnosticDescriptors.ClassMustBePartial, + Location.None, // We don't have class location easily accessible here + first.ClassName); + return AnalysisResult.WithDiagnostics( + allDiagnostics.Select(d => d.ToDiagnostic(null)).Append(diag).ToImmutableArray()); + } + + if (first.HasManualConfigureRoutes) + { + var diag = Diagnostic.Create( + DiagnosticDescriptors.ConfigureRoutesAlreadyDefined, + Location.None, + first.ClassName); + return AnalysisResult.WithDiagnostics( + allDiagnostics.Select(d => d.ToDiagnostic(null)).Append(diag).ToImmutableArray()); + } + + // Collect valid handlers + var handlers = methods + .Where(m => m.Handler is not null) + .Select(m => m.Handler!) + .ToImmutableArray(); + + if (handlers.Length == 0) + { + return AnalysisResult.WithDiagnostics( + allDiagnostics.Select(d => d.ToDiagnostic(null)).ToImmutableArray()); + } + + var executorInfo = new ExecutorInfo( + first.Namespace, + first.ClassName, + first.GenericParameters, + first.IsNested, + first.ContainingTypeChain, + first.BaseHasConfigureRoutes, + new EquatableArray(handlers), + first.ClassSendTypes, + first.ClassYieldTypes); + + if (allDiagnostics.Length > 0) + { + return AnalysisResult.WithInfoAndDiagnostics( + executorInfo, + allDiagnostics.Select(d => d.ToDiagnostic(null)).ToImmutableArray()); + } + + return AnalysisResult.Success(executorInfo); + } + + private static MethodAnalysisResult CreateEmptyResult() + { + return new MethodAnalysisResult( + string.Empty, null, string.Empty, null, false, string.Empty, + false, EquatableArray.Empty, EquatableArray.Empty, + false, false, false, + null, EquatableArray.Empty); + } + + private static string GetClassKey(INamedTypeSymbol classSymbol) + { + return classSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + private static bool IsPartialClass(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) + { + foreach (var syntaxRef in classSymbol.DeclaringSyntaxReferences) + { + var syntax = syntaxRef.GetSyntax(cancellationToken); + if (syntax is ClassDeclarationSyntax classDecl && + classDecl.Modifiers.Any(SyntaxKind.PartialKeyword)) + { + return true; + } + } + + return false; + } + + private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) + { + var current = classSymbol.BaseType; + while (current != null) + { + var fullName = current.OriginalDefinition.ToDisplayString(); + if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", StringComparison.Ordinal)) + { + return true; + } + + current = current.BaseType; + } + + return false; + } + + 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; + } + + private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol) + { + var baseType = classSymbol.BaseType; + while (baseType != null) + { + var fullName = baseType.OriginalDefinition.ToDisplayString(); + 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; + } + + private static HandlerInfo? AnalyzeHandler( + IMethodSymbol methodSymbol, + MethodDeclarationSyntax? methodSyntax, + ImmutableArray.Builder diagnostics) + { + var location = methodSyntax?.Identifier.GetLocation() ?? Location.None; + + // Check if static + if (methodSymbol.IsStatic) + { + diagnostics.Add(DiagnosticInfo.Create("WFGEN007", location, methodSymbol.Name)); + return null; + } + + // Check parameter count + if (methodSymbol.Parameters.Length < 2) + { + diagnostics.Add(DiagnosticInfo.Create("WFGEN005", location, methodSymbol.Name)); + return null; + } + + // Check second parameter is IWorkflowContext + var secondParam = methodSymbol.Parameters[1]; + if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName) + { + diagnostics.Add(DiagnosticInfo.Create("WFGEN001", location, methodSymbol.Name)); + return null; + } + + // Check for optional CancellationToken as third parameter + var hasCancellationToken = methodSymbol.Parameters.Length >= 3 && + methodSymbol.Parameters[2].Type.ToDisplayString() == CancellationTokenTypeName; + + // Analyze return type + var returnType = methodSymbol.ReturnType; + var signatureKind = GetSignatureKind(returnType); + if (signatureKind == null) + { + diagnostics.Add(DiagnosticInfo.Create("WFGEN002", location, methodSymbol.Name)); + return null; + } + + // Get input type + var inputType = methodSymbol.Parameters[0].Type; + var 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 + var (yieldTypes, sendTypes) = GetAttributeTypeArrays(methodSymbol); + + return new HandlerInfo( + methodSymbol.Name, + inputTypeName, + outputTypeName, + signatureKind.Value, + hasCancellationToken, + yieldTypes, + sendTypes); + } + + private static HandlerSignatureKind? GetSignatureKind(ITypeSymbol returnType) + { + var 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") + { + return HandlerSignatureKind.ResultAsync; + } + + 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; + } + + return null; + } + + private static (EquatableArray YieldTypes, EquatableArray SendTypes) GetAttributeTypeArrays( + IMethodSymbol methodSymbol) + { + var yieldTypes = ImmutableArray.Empty; + var sendTypes = ImmutableArray.Empty; + + foreach (var attr in methodSymbol.GetAttributes()) + { + if (attr.AttributeClass?.ToDisplayString() != MessageHandlerAttributeName) + { + continue; + } + + foreach (var namedArg in attr.NamedArguments) + { + if (namedArg.Key == "Yield" && !namedArg.Value.IsNull) + { + yieldTypes = ExtractTypeArray(namedArg.Value); + } + else if (namedArg.Key == "Send" && !namedArg.Value.IsNull) + { + sendTypes = ExtractTypeArray(namedArg.Value); + } + } + } + + return (new EquatableArray(yieldTypes), new EquatableArray(sendTypes)); + } + + private static ImmutableArray ExtractTypeArray(TypedConstant typedConstant) + { + if (typedConstant.Kind != TypedConstantKind.Array) + { + return ImmutableArray.Empty; + } + + var builder = ImmutableArray.CreateBuilder(); + foreach (var value in typedConstant.Values) + { + if (value.Value is INamedTypeSymbol typeSymbol) + { + builder.Add(typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + } + + return builder.ToImmutable(); + } + + private static EquatableArray GetClassLevelTypes(INamedTypeSymbol classSymbol, string attributeName) + { + var builder = ImmutableArray.CreateBuilder(); + + foreach (var 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)); + } + } + + return new EquatableArray(builder.ToImmutable()); + } + + private static string GetContainingTypeChain(INamedTypeSymbol classSymbol) + { + var chain = new List(); + var current = classSymbol.ContainingType; + + while (current != null) + { + chain.Insert(0, current.Name); + current = current.ContainingType; + } + + return string.Join(".", chain); + } + + private static string? GetGenericParameters(INamedTypeSymbol classSymbol) + { + if (!classSymbol.IsGenericType) + { + return null; + } + + var parameters = string.Join(", ", classSymbol.TypeParameters.Select(p => p.Name)); + return $"<{parameters}>"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000000..161b1e82ec --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Agents.AI.Workflows.Generators.Diagnostics; + +/// +/// Diagnostic descriptors for the executor route source generator. +/// +internal static class DiagnosticDescriptors +{ + private const string Category = "Microsoft.Agents.AI.Workflows.Generators"; + + private static readonly Dictionary s_descriptorsById = new(); + + /// + /// Gets a diagnostic descriptor by its ID. + /// + 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; + } + + /// + /// WFGEN001: Handler method must have IWorkflowContext parameter. + /// + public static readonly DiagnosticDescriptor MissingWorkflowContext = Register(new( + id: "WFGEN001", + 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)); + + /// + /// WFGEN002: Handler method has invalid return type. + /// + public static readonly DiagnosticDescriptor InvalidReturnType = Register(new( + id: "WFGEN002", + title: "Handler has invalid return type", + messageFormat: "Method '{0}' marked with [MessageHandler] must return void, ValueTask, or ValueTask", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true)); + + /// + /// WFGEN003: Executor with [MessageHandler] must be partial. + /// + public static readonly DiagnosticDescriptor ClassMustBePartial = Register(new( + id: "WFGEN003", + 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)); + + /// + /// WFGEN004: [MessageHandler] on non-Executor class. + /// + public static readonly DiagnosticDescriptor NotAnExecutor = Register(new( + id: "WFGEN004", + 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)); + + /// + /// WFGEN005: Handler method has insufficient parameters. + /// + public static readonly DiagnosticDescriptor InsufficientParameters = Register(new( + id: "WFGEN005", + 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)); + + /// + /// WFGEN006: ConfigureRoutes already defined. + /// + public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new( + id: "WFGEN006", + title: "ConfigureRoutes already defined", + messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true)); + + /// + /// WFGEN007: Handler method is static. + /// + public static readonly DiagnosticDescriptor HandlerCannotBeStatic = Register(new( + id: "WFGEN007", + title: "Handler cannot be static", + messageFormat: "Method '{0}' marked with [MessageHandler] cannot be static", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true)); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs new file mode 100644 index 0000000000..598353e5e3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +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; + +/// +/// Roslyn incremental source generator that generates ConfigureRoutes implementations +/// for executor classes with [MessageHandler] attributed methods. +/// Uses ForAttributeWithMetadataName for optimal performance (99x+ faster than CreateSyntaxProvider). +/// +[Generator] +public sealed class ExecutorRouteGenerator : IIncrementalGenerator +{ + private const string MessageHandlerAttributeFullName = "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Step 1: Use ForAttributeWithMetadataName to efficiently find methods with [MessageHandler] + // This is 99x+ more efficient than CreateSyntaxProvider according to the Roslyn cookbook + var methodAnalysisResults = context.SyntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName: MessageHandlerAttributeFullName, + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeMethod(ctx, ct)) + .Where(static result => !string.IsNullOrEmpty(result.ClassKey)); + + // Step 2: Collect all results and group by class + var groupedByClass = methodAnalysisResults + .Collect() + .SelectMany(static (results, _) => + { + // Group by class key and combine into AnalysisResult + return results + .GroupBy(r => r.ClassKey) + .Select(group => SemanticAnalyzer.CombineMethodResults(group)); + }); + + // Step 3: Generate source for valid executors + context.RegisterSourceOutput( + groupedByClass.Where(static r => r.ExecutorInfo is not null), + static (ctx, result) => + { + var source = SourceBuilder.Generate(result.ExecutorInfo!); + var hintName = GetHintName(result.ExecutorInfo!); + ctx.AddSource(hintName, SourceText.From(source, Encoding.UTF8)); + }); + + // Step 4: Report diagnostics + context.RegisterSourceOutput( + groupedByClass.Where(static r => !r.Diagnostics.IsEmpty), + static (ctx, result) => + { + foreach (var diagnostic in result.Diagnostics) + { + ctx.ReportDiagnostic(diagnostic); + } + }); + } + + private static string GetHintName(ExecutorInfo info) + { + var sb = new StringBuilder(); + + if (!string.IsNullOrEmpty(info.Namespace)) + { + sb.Append(info.Namespace); + sb.Append('.'); + } + + if (info.IsNested) + { + sb.Append(info.ContainingTypeChain); + sb.Append('.'); + } + + sb.Append(info.ClassName); + + // Handle generic type parameters in hint name + if (!string.IsNullOrEmpty(info.GenericParameters)) + { + // Replace < > with underscores for valid file name + sb.Append('_'); + sb.Append(info.GenericParameters!.Length - 2); // Number of type params approximation + } + + sb.Append(".g.cs"); + + return sb.ToString(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs new file mode 100644 index 0000000000..89434b7027 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs @@ -0,0 +1,221 @@ +// 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; + +/// +/// Generates source code for executor route configuration. +/// +internal static class SourceBuilder +{ + /// + /// Generates the source code for an executor's route configuration. + /// + public static string Generate(ExecutorInfo info) + { + var sb = new StringBuilder(); + + // File header + sb.AppendLine("// "); + 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.IsNullOrEmpty(info.Namespace)) + { + sb.AppendLine($"namespace {info.Namespace};"); + sb.AppendLine(); + } + + // Handle nested classes + var indent = ""; + if (info.IsNested) + { + foreach (var 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}{{"); + + var memberIndent = indent + " "; + + // Generate ConfigureRoutes + GenerateConfigureRoutes(sb, info, memberIndent); + + // Generate ConfigureSentTypes if needed + if (info.ShouldGenerateProtocolOverrides) + { + sb.AppendLine(); + GenerateConfigureSentTypes(sb, info, memberIndent); + sb.AppendLine(); + GenerateConfigureYieldTypes(sb, info, memberIndent); + } + + // Close class + sb.AppendLine($"{indent}}}"); + + // Close nested classes + if (info.IsNested) + { + var containingTypes = info.ContainingTypeChain.Split('.'); + for (int i = containingTypes.Length - 1; i >= 0; i--) + { + indent = new string(' ', i * 4); + sb.AppendLine($"{indent}}}"); + } + } + + return sb.ToString(); + } + + private static void GenerateConfigureRoutes(StringBuilder sb, ExecutorInfo info, string indent) + { + sb.AppendLine($"{indent}protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)"); + sb.AppendLine($"{indent}{{"); + + var bodyIndent = indent + " "; + + // Call base if needed + if (info.BaseHasConfigureRoutes) + { + sb.AppendLine($"{bodyIndent}routeBuilder = base.ConfigureRoutes(routeBuilder);"); + sb.AppendLine(); + } + + // Generate handler registrations + if (info.Handlers.Length == 1) + { + // Single handler - return directly + var 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 calls + sb.AppendLine($"{bodyIndent}return routeBuilder"); + + for (int i = 0; i < info.Handlers.Length; i++) + { + var handler = info.Handlers[i]; + var isLast = i == info.Handlers.Length - 1; + + sb.Append($"{bodyIndent} .AddHandler"); + AppendHandlerGenericArgs(sb, handler); + sb.Append($"(this.{handler.MethodName})"); + + if (isLast) + { + sb.AppendLine(";"); + } + else + { + sb.AppendLine(); + } + } + } + + sb.AppendLine($"{indent}}}"); + } + + private static void AppendHandlerGenericArgs(StringBuilder sb, HandlerInfo handler) + { + if (handler.HasOutput && handler.OutputTypeName != null) + { + sb.Append($"<{handler.InputTypeName}, {handler.OutputTypeName}>"); + } + else + { + sb.Append($"<{handler.InputTypeName}>"); + } + } + + private static void GenerateConfigureSentTypes(StringBuilder sb, ExecutorInfo info, string indent) + { + sb.AppendLine($"{indent}protected override ISet ConfigureSentTypes()"); + sb.AppendLine($"{indent}{{"); + + var bodyIndent = indent + " "; + + sb.AppendLine($"{bodyIndent}var types = base.ConfigureSentTypes();"); + + // Add class-level send types + foreach (var type in info.ClassSendTypes) + { + sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));"); + } + + // Add handler-level send types + 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}}}"); + } + + private static void GenerateConfigureYieldTypes(StringBuilder sb, ExecutorInfo info, string indent) + { + sb.AppendLine($"{indent}protected override ISet ConfigureYieldTypes()"); + sb.AppendLine($"{indent}{{"); + + var bodyIndent = indent + " "; + + sb.AppendLine($"{bodyIndent}var types = base.ConfigureYieldTypes();"); + + // Track types we've already added to avoid duplicates + var addedTypes = new HashSet(); + + // Add class-level yield types + foreach (var type in info.ClassYieldTypes) + { + if (addedTypes.Add(type)) + { + sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));"); + } + } + + // Add handler-level yield types and output types + foreach (var handler in info.Handlers) + { + // Add explicit yield types + foreach (var type in handler.YieldTypes) + { + if (addedTypes.Add(type)) + { + sb.AppendLine($"{bodyIndent}types.Add(typeof({type}));"); + } + } + + // Add output type from return type + 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}}}"); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj new file mode 100644 index 0000000000..1f82a3ba73 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -0,0 +1,48 @@ + + + + + + + netstandard2.0 + latest + enable + + + true + + + true + true + false + + + $(NoWarn);nullable + + $(NoWarn);RS2008 + + + + preview + + + + + + + Microsoft Agent Framework Workflows Source Generators + Provides Roslyn source generators for Microsoft Agent Framework Workflows, enabling compile-time route configuration for executors. + true + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs new file mode 100644 index 0000000000..3576e903d2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// Represents the result of analyzing a class with [MessageHandler] methods. +/// Combines the executor info (if valid) with any diagnostics to report. +/// Note: This type is used after the caching layer (in RegisterSourceOutput), +/// so it can contain Diagnostic objects directly. +/// +internal sealed class AnalysisResult +{ + public ExecutorInfo? ExecutorInfo { get; } + public ImmutableArray Diagnostics { get; } + + public AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray diagnostics) + { + ExecutorInfo = executorInfo; + Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + } + + /// + /// Creates a successful result with executor info and no diagnostics. + /// + public static AnalysisResult Success(ExecutorInfo info) => + new(info, ImmutableArray.Empty); + + /// + /// Creates a result with only diagnostics (no valid executor info). + /// + public static AnalysisResult WithDiagnostics(ImmutableArray diagnostics) => + new(null, diagnostics); + + /// + /// Creates a result with executor info and diagnostics. + /// + public static AnalysisResult WithInfoAndDiagnostics(ExecutorInfo info, ImmutableArray diagnostics) => + new(info, diagnostics); + + /// + /// Creates an empty result (no info, no diagnostics). + /// + public static AnalysisResult Empty => new(null, ImmutableArray.Empty); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs new file mode 100644 index 0000000000..b76c723f64 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs @@ -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; + +/// +/// 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. +/// +internal sealed record DiagnosticInfo( + string DiagnosticId, + string FilePath, + TextSpan Span, + LinePositionSpan LineSpan, + EquatableArray MessageArgs) +{ + /// + /// Creates a DiagnosticInfo from a location and message arguments. + /// + public static DiagnosticInfo Create(string diagnosticId, Location location, params string[] messageArgs) + { + var lineSpan = location.GetLineSpan(); + return new DiagnosticInfo( + diagnosticId, + lineSpan.Path ?? string.Empty, + location.SourceSpan, + lineSpan.Span, + new EquatableArray(System.Collections.Immutable.ImmutableArray.Create(messageArgs))); + } + + /// + /// Converts this info back to a Diagnostic. + /// + public Diagnostic ToDiagnostic(SyntaxTree? syntaxTree) + { + var descriptor = DiagnosticDescriptors.GetById(DiagnosticId); + if (descriptor is null) + { + // Fallback - should not happen + var fallbackArgs = new object[MessageArgs.Length]; + for (int i = 0; i < MessageArgs.Length; i++) + { + fallbackArgs[i] = MessageArgs[i]; + } + + return Diagnostic.Create( + DiagnosticDescriptors.InsufficientParameters, + Location.None, + fallbackArgs); + } + + Location location; + if (syntaxTree is not null) + { + location = Location.Create(syntaxTree, Span); + } + else if (!string.IsNullOrEmpty(FilePath)) + { + location = Location.Create(FilePath, Span, LineSpan); + } + else + { + location = Location.None; + } + + var args = new object[MessageArgs.Length]; + for (int i = 0; i < MessageArgs.Length; i++) + { + args[i] = MessageArgs[i]; + } + + return Diagnostic.Create(descriptor, location, args); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs new file mode 100644 index 0000000000..32122152a0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// A wrapper around that provides value-based equality. +/// This is necessary for incremental generator caching since ImmutableArray uses reference equality. +/// +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array; + + /// + /// Creates a new from an . + /// + public EquatableArray(ImmutableArray array) + { + _array = array.IsDefault ? ImmutableArray.Empty : array; + } + + /// + /// Gets the underlying array. + /// + public ImmutableArray AsImmutableArray() => _array; + + /// + /// Gets the number of elements in the array. + /// + public int Length => _array.Length; + + /// + /// Gets the element at the specified index. + /// + public T this[int index] => _array[index]; + + /// + /// Gets whether the array is empty. + /// + public bool IsEmpty => _array.IsEmpty; + + /// + public bool Equals(EquatableArray other) + { + if (_array.Length != other._array.Length) + { + return false; + } + + for (int i = 0; i < _array.Length; i++) + { + if (!_array[i].Equals(other._array[i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) + { + return obj is EquatableArray other && Equals(other); + } + + /// + public override int GetHashCode() + { + if (_array.IsEmpty) + { + return 0; + } + + var hashCode = 17; + foreach (var item in _array) + { + hashCode = hashCode * 31 + item?.GetHashCode() ?? 0; + } + + return hashCode; + } + + /// + public IEnumerator GetEnumerator() + { + return ((IEnumerable)_array).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Equality operator. + /// + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + /// + /// Inequality operator. + /// + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + + /// + /// Creates an empty . + /// + public static EquatableArray Empty => new(ImmutableArray.Empty); + + /// + /// Implicit conversion from . + /// + public static implicit operator EquatableArray(ImmutableArray array) => new(array); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs new file mode 100644 index 0000000000..9d1a036613 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// Contains all information needed to generate code for an executor class. +/// Uses record for automatic value equality, which is required for incremental generator caching. +/// +/// The namespace of the executor class. +/// The name of the executor class. +/// The generic type parameters of the class (e.g., "<T, U>"), or null if not generic. +/// Whether the class is nested inside another class. +/// The chain of containing types for nested classes (e.g., "OuterClass.InnerClass"). Empty string if not nested. +/// Whether the base class has a ConfigureRoutes method that should be called. +/// The list of handler methods to register. +/// The types declared via class-level [SendsMessage] attributes. +/// The types declared via class-level [YieldsMessage] attributes. +internal sealed record ExecutorInfo( + string? Namespace, + string ClassName, + string? GenericParameters, + bool IsNested, + string ContainingTypeChain, + bool BaseHasConfigureRoutes, + EquatableArray Handlers, + EquatableArray ClassSendTypes, + EquatableArray ClassYieldTypes) +{ + /// + /// Gets whether any protocol type overrides should be generated. + /// + public bool ShouldGenerateProtocolOverrides => + !ClassSendTypes.IsEmpty || + !ClassYieldTypes.IsEmpty || + HasHandlerWithSendTypes || + HasHandlerWithYieldTypes; + + /// + /// Gets whether any handler has explicit Send types. + /// + public bool HasHandlerWithSendTypes + { + get + { + foreach (var handler in Handlers) + { + if (!handler.SendTypes.IsEmpty) + { + return true; + } + } + + return false; + } + } + + /// + /// Gets whether any handler has explicit Yield types or output types. + /// + public bool HasHandlerWithYieldTypes + { + get + { + foreach (var handler in Handlers) + { + if (!handler.YieldTypes.IsEmpty) + { + return true; + } + + if (handler.HasOutput) + { + return true; + } + } + + return false; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs new file mode 100644 index 0000000000..0cae0f2ba4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// Represents the signature kind of a message handler method. +/// +internal enum HandlerSignatureKind +{ + /// Void synchronous: void Handler(T, IWorkflowContext) or void Handler(T, IWorkflowContext, CT) + VoidSync, + + /// Void asynchronous: ValueTask Handler(T, IWorkflowContext[, CT]) + VoidAsync, + + /// Result synchronous: TResult Handler(T, IWorkflowContext[, CT]) + ResultSync, + + /// Result asynchronous: ValueTask<TResult> Handler(T, IWorkflowContext[, CT]) + ResultAsync +} + +/// +/// Contains information about a single message handler method. +/// Uses record for automatic value equality, which is required for incremental generator caching. +/// +/// The name of the handler method. +/// The fully-qualified type name of the input message type. +/// The fully-qualified type name of the output type, or null if the handler is void. +/// The signature kind of the handler. +/// Whether the handler method has a CancellationToken parameter. +/// The types explicitly declared in the Yield property of [MessageHandler]. +/// The types explicitly declared in the Send property of [MessageHandler]. +internal sealed record HandlerInfo( + string MethodName, + string InputTypeName, + string? OutputTypeName, + HandlerSignatureKind SignatureKind, + bool HasCancellationToken, + EquatableArray YieldTypes, + EquatableArray SendTypes) +{ + /// + /// Gets whether this handler returns a value (either sync or async). + /// + public bool HasOutput => SignatureKind == HandlerSignatureKind.ResultSync || SignatureKind == HandlerSignatureKind.ResultAsync; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs new file mode 100644 index 0000000000..7e1e2f4fb7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// 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. +/// +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, + EquatableArray ClassSendTypes, + EquatableArray ClassYieldTypes, + + // Class-level validation results + bool IsPartialClass, + bool DerivesFromExecutor, + bool HasManualConfigureRoutes, + + // Method-level info (null if method validation failed) + HandlerInfo? Handler, + + // Any diagnostics from analyzing this method (uses DiagnosticInfo for value equality) + EquatableArray Diagnostics); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs new file mode 100644 index 0000000000..14b413d4e2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Marks a method as a message handler for source-generated route configuration. +/// The method signature determines the input type and optional output type. +/// +/// +/// +/// Methods marked with this attribute must have a signature matching one of the following patterns: +/// +/// void Handler(TMessage, IWorkflowContext) +/// void Handler(TMessage, IWorkflowContext, CancellationToken) +/// ValueTask Handler(TMessage, IWorkflowContext) +/// ValueTask Handler(TMessage, IWorkflowContext, CancellationToken) +/// TResult Handler(TMessage, IWorkflowContext) +/// TResult Handler(TMessage, IWorkflowContext, CancellationToken) +/// ValueTask<TResult> Handler(TMessage, IWorkflowContext) +/// ValueTask<TResult> Handler(TMessage, IWorkflowContext, CancellationToken) +/// +/// +/// +/// The containing class must be partial and derive from . +/// +/// +/// +/// +/// public partial class MyExecutor : Executor +/// { +/// [MessageHandler] +/// private async ValueTask<MyResponse> 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 +/// } +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + /// + /// Gets or sets the types that this handler may yield as workflow outputs. + /// + /// + /// 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. + /// + public Type[]? Yield { get; set; } + + /// + /// Gets or sets the types that this handler may send as messages to other executors. + /// + /// + /// Use this property to declare the message types that this handler may send + /// via during its execution. + /// This information is used for protocol validation and documentation. + /// + public Type[]? Send { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs new file mode 100644 index 0000000000..53b758144c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Declares that an executor may send messages of the specified type. +/// +/// +/// +/// Apply this attribute to an class to declare the types of messages +/// it may send via . This information is used +/// for protocol validation and documentation. +/// +/// +/// 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. +/// +/// +/// +/// +/// [SendsMessage(typeof(PollToken))] +/// [SendsMessage(typeof(StatusUpdate))] +/// public partial class MyExecutor : Executor +/// { +/// // ... +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + /// + /// Gets the type of message that the executor may send. + /// + public Type Type { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The type of message that the executor may send. + /// is . + public SendsMessageAttribute(Type type) + { + this.Type = Throw.IfNull(type); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs new file mode 100644 index 0000000000..e79baa3ebf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Declares that an executor may yield messages of the specified type as workflow outputs. +/// +/// +/// +/// Apply this attribute to an class to declare the types of messages +/// it may yield via . This information is used +/// for protocol validation and documentation. +/// +/// +/// 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. +/// +/// +/// +/// +/// [YieldsMessage(typeof(FinalResult))] +/// [YieldsMessage(typeof(StreamChunk))] +/// public partial class MyExecutor : Executor +/// { +/// // ... +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + /// + /// Gets the type of message that the executor may yield. + /// + public Type Type { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The type of message that the executor may yield. + /// is . + public YieldsMessageAttribute(Type type) + { + this.Type = Throw.IfNull(type); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 647dbcd852..c90559da53 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#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.Diagnostics; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj index 7379d9a6ac..3ecf31e132 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj @@ -25,6 +25,15 @@ + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs index 3b18379907..fe1a777859 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading; using System.Threading.Tasks; @@ -9,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// A message handler interface for handling messages of type . /// /// +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// @@ -28,6 +35,12 @@ public interface IMessageHandler /// /// The type of message to handle. /// The type of result returned after handling the message. +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs index f63a43b4a8..f655c27cd4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility + using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index d96f9319f4..f4dcf1291f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI.Workflows.Reflection; @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// The actual type of the . /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// +/// +/// This type is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This type will be removed in a future version.")] public class ReflectingExecutor< [DynamicallyAccessedMembers( ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs index f25f896db9..d554138f1e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#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.Diagnostics; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 12079289a4..234958a98a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility + using System; using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs new file mode 100644 index 0000000000..a846bf4355 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -0,0 +1,554 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.Generators.UnitTests; + +/// +/// Tests for the ExecutorRouteGenerator source generator. +/// +public class ExecutorRouteGeneratorTests +{ + #region Single Handler Tests + + [Fact] + public void SingleHandler_VoidReturn_GeneratesCorrectRoute() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) + { + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + 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(this.HandleMessage)"); + } + + [Fact] + public void SingleHandler_ValueTaskReturn_GeneratesCorrectRoute() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private ValueTask HandleMessageAsync(string message, IWorkflowContext context) + { + return default; + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain(".AddHandler(this.HandleMessageAsync)"); + } + + [Fact] + public void SingleHandler_WithOutput_GeneratesCorrectRoute() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private ValueTask HandleMessageAsync(string message, IWorkflowContext context) + { + return new ValueTask(42); + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain(".AddHandler(this.HandleMessageAsync)"); + } + + [Fact] + public void SingleHandler_WithCancellationToken_GeneratesCorrectRoute() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private ValueTask HandleMessageAsync(string message, IWorkflowContext context, CancellationToken ct) + { + return default; + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain(".AddHandler(this.HandleMessageAsync)"); + } + + #endregion + + #region Multiple Handler Tests + + [Fact] + public void MultipleHandlers_GeneratesAllRoutes() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleString(string message, IWorkflowContext context) { } + + [MessageHandler] + private void HandleInt(int message, IWorkflowContext context) { } + + [MessageHandler] + private ValueTask HandleDoubleAsync(double message, IWorkflowContext context) + { + return new ValueTask("result"); + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain(".AddHandler(this.HandleString)"); + generated.Should().Contain(".AddHandler(this.HandleInt)"); + generated.Should().Contain(".AddHandler(this.HandleDoubleAsync)"); + } + + #endregion + + #region Yield and Send Type Tests + + [Fact] + public void Handler_WithYieldTypes_GeneratesConfigureYieldTypes() + { + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class OutputMessage { } + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler(Yield = new[] { typeof(OutputMessage) })] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("protected override ISet ConfigureYieldTypes()"); + generated.Should().Contain("types.Add(typeof(global::TestNamespace.OutputMessage))"); + } + + [Fact] + public void Handler_WithSendTypes_GeneratesConfigureSentTypes() + { + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class SendMessage { } + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler(Send = new[] { typeof(SendMessage) })] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("protected override ISet ConfigureSentTypes()"); + generated.Should().Contain("types.Add(typeof(global::TestNamespace.SendMessage))"); + } + + [Fact] + public void ClassLevel_SendsMessageAttribute_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") { } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("protected override ISet ConfigureSentTypes()"); + generated.Should().Contain("types.Add(typeof(global::TestNamespace.BroadcastMessage))"); + } + + [Fact] + public void ClassLevel_YieldsMessageAttribute_GeneratesConfigureYieldTypes() + { + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class YieldedMessage { } + + [YieldsMessage(typeof(YieldedMessage))] + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("protected override ISet ConfigureYieldTypes()"); + generated.Should().Contain("types.Add(typeof(global::TestNamespace.YieldedMessage))"); + } + + #endregion + + #region Nested Class Tests + + [Fact] + public void NestedClass_GeneratesCorrectPartialHierarchy() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class OuterClass + { + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("partial class OuterClass"); + generated.Should().Contain("partial class TestExecutor"); + } + + #endregion + + #region Diagnostic Tests + + [Fact] + public void NonPartialClass_ProducesDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN003"); + } + + [Fact] + public void NonExecutorClass_ProducesDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class NotAnExecutor + { + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN004"); + } + + [Fact] + public void StaticHandler_ProducesDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private static void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN007"); + } + + [Fact] + public void MissingWorkflowContext_ProducesDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN005"); + } + + [Fact] + public void WrongSecondParameter_ProducesDiagnostic() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + [MessageHandler] + private void HandleMessage(string message, string notContext) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN001"); + } + + #endregion + + #region No Generation Tests + + [Fact] + public void ClassWithManualConfigureRoutes_DoesNotGenerate() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder; + } + + [MessageHandler] + private void HandleMessage(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + // Should produce diagnostic but not generate code + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN006"); + result.RunResult.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void ClassWithNoMessageHandlers_DoesNotGenerate() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class TestExecutor : Executor + { + public TestExecutor() : base("test") { } + + private void SomeOtherMethod(string message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().BeEmpty(); + } + + #endregion + + #region Generic Executor Tests + + [Fact] + public void GenericExecutor_GeneratesCorrectly() + { + var source = """ + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public partial class GenericExecutor : Executor where T : class + { + public GenericExecutor() : base("generic") { } + + [MessageHandler] + private void HandleMessage(T message, IWorkflowContext context) { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + generated.Should().Contain("partial class GenericExecutor"); + generated.Should().Contain(".AddHandler(this.HandleMessage)"); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs new file mode 100644 index 0000000000..2a6342231a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs @@ -0,0 +1,139 @@ +// 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; + +/// +/// Helper class for testing the ExecutorRouteGenerator. +/// +public static class GeneratorTestHelper +{ + /// + /// Runs the ExecutorRouteGenerator on the provided source code and returns the result. + /// + public static GeneratorRunResult RunGenerator(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + var references = GetMetadataReferences(); + + var compilation = CSharpCompilation.Create( + assemblyName: "TestAssembly", + syntaxTrees: [syntaxTree], + 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); + } + + /// + /// Runs the generator and asserts that it produces exactly one generated file with the expected content. + /// + 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); + } + + /// + /// Runs the generator and asserts that no source is generated. + /// + public static void AssertGeneratesNoSource(string source) + { + var result = RunGenerator(source); + result.RunResult.GeneratedTrees.Should().BeEmpty("expected no generated files"); + } + + /// + /// Runs the generator and asserts that a specific diagnostic is produced. + /// + 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"); + } + + /// + /// Runs the generator and asserts that compilation succeeds with no errors. + /// + 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 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(); + + 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()]; + } +} + +/// +/// Contains the results of running the generator. +/// +public record GeneratorRunResult( + GeneratorDriverRunResult RunResult, + Compilation OutputCompilation, + ImmutableArray Diagnostics); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj new file mode 100644 index 0000000000..81b91bf17d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj @@ -0,0 +1,23 @@ + + + + + net10.0 + + $(NoWarn);RCS1118 + + + + + + + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs index ccf3f7bc8b..55aefa0133 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index c6d33e13d7..5af52874f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Linq; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 9ee50ae3fb..d44b0babcd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Linq; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 62ba2a8a68..61e063df32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Threading; diff --git a/dotnet/wf-code-gen-impact.md b/dotnet/wf-code-gen-impact.md new file mode 100644 index 0000000000..b49c8c0594 --- /dev/null +++ b/dotnet/wf-code-gen-impact.md @@ -0,0 +1,257 @@ +# Source Generator for Workflow Executors: Rationale and Impact + +## Overview + +The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users. + +## Why Move from Reflection to Code Generation? + +### The Previous Approach: `ReflectingExecutor` + +Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor` and implemented marker interfaces like `IMessageHandler`: + +```csharp +// Old approach - reflection-based +public class MyExecutor : ReflectingExecutor, + IMessageHandler, + IMessageHandler +{ + public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + public ValueTask HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +This approach had several limitations: + +1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization +2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation +3. **Redundant declarations**: The interface list duplicated information already present in method signatures +4. **Limited metadata**: No clean way to declare yield/send types for protocol validation +5. **Hidden errors**: Invalid handler signatures weren't caught until runtime + +### The New Approach: `[MessageHandler]` Attribute + +The source generator enables a cleaner, attribute-based pattern: + +```csharp +// New approach - source generated +[SendsMessage(typeof(PollToken))] +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private ValueTask HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time. + +## What's Better About Code Generation? + +### 1. Compile-Time Validation + +Invalid handler signatures are caught during compilation, not at runtime: + +```csharp +[MessageHandler] +private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter +{ +} +``` + +Diagnostic errors include: +- `WFGEN001`: Handler missing `IWorkflowContext` parameter +- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask`) +- `WFGEN003`: Executor class must be `partial` +- `WFGEN004`: `[MessageHandler]` on non-Executor class +- `WFGEN005`: Insufficient parameters +- `WFGEN006`: `ConfigureRoutes` already manually defined + +### 2. Zero Runtime Reflection + +All handler registration happens at compile time. The generated code is simple, direct method calls: + +```csharp +// Generated code +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleCommandAsync); +} +``` + +This eliminates: +- Reflection overhead during initialization +- Assembly scanning +- Dynamic delegate creation + +### 3. Native AOT Compatibility + +Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables: +- Faster startup times +- Smaller deployment sizes +- Deployment to environments that don't support JIT compilation + +### 4. Explicit Protocol Metadata + +The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation: + +```csharp +[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages +[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output +public partial class MyExecutor : Executor +{ + [MessageHandler( + Yield = [typeof(StreamChunk)], // This handler yields StreamChunk + Send = [typeof(InternalQuery)])] // This handler sends InternalQuery + private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... } +} +``` + +This metadata enables: +- Static protocol validation +- Better IDE tooling and documentation +- Clearer code intent + +### 5. Handler Accessibility Freedom + +Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations: + +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx) + { + // Private handler - implementation detail + } +} +``` + +### 6. Cleaner Inheritance + +The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate: + +```csharp +public partial class DerivedExecutor : BaseExecutor +{ + [MessageHandler] + private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... } +} + +// Generated: +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers + return routeBuilder + .AddHandler(this.HandleDerivedAsync); +} +``` + +## New Capabilities Enabled + +### 1. Static Workflow Analysis + +With explicit yield/send metadata, tools can analyze workflow graphs at compile time: +- Validate that all message types have handlers +- Detect unreachable executors +- Generate workflow documentation + +### 2. Trimming-Safe Deployments + +The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios. + +### 3. Better IDE Experience + +Because the generator runs in the IDE, you get: +- Immediate feedback on handler signature errors +- IntelliSense for generated methods +- Go-to-definition on generated code + +### 4. Protocol Documentation Generation + +The explicit type metadata can be used to generate: +- API documentation +- OpenAPI/Swagger specs for workflow endpoints +- Visual workflow diagrams + +## Impact on Framework Users + +### Migration Path + +Existing code using `ReflectingExecutor` continues to work but is marked `[Obsolete]`. To migrate: + +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Replace `IMessageHandler` interfaces with `[MessageHandler]` attributes +4. Optionally add `Yield`/`Send` metadata for protocol validation + +**Before:** +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public ValueTask HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +**After:** +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +### Breaking Changes + +- Classes using `[MessageHandler]` **must** be `partial` +- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)` +- Return type must be `void`, `ValueTask`, or `ValueTask` + +### Performance Improvements + +Users can expect: +- **Faster executor initialization**: No reflection overhead +- **Reduced memory allocation**: No dynamic delegate creation +- **AOT deployment support**: Full Native AOT compatibility +- **Smaller trimmed deployments**: No reflection metadata preserved + +### NuGet Package + +The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it: +- Runs automatically during build +- Requires no additional configuration +- Works in all IDEs that support Roslyn analyzers + +## Summary + +The move from reflection to source generation represents a significant improvement in the Workflows framework: + +| Aspect | Reflection (Old) | Source Generator (New) | +|--------|------------------|------------------------| +| Handler discovery | Runtime | Compile-time | +| Error detection | Runtime exceptions | Compiler errors | +| AOT support | No | Yes | +| Trimming support | Limited | Full | +| Protocol metadata | Implicit | Explicit | +| Handler visibility | Public only | Any | +| Initialization speed | Slower | Faster | + +The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential. diff --git a/dotnet/wf-source-gen-bp.md b/dotnet/wf-source-gen-bp.md new file mode 100644 index 0000000000..c0f3d25892 --- /dev/null +++ b/dotnet/wf-source-gen-bp.md @@ -0,0 +1,439 @@ +# Source Generator Best Practices Review + +This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository. + +## Reference Documentation + +- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md) +- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md) + +--- + +## Executive Summary + +| Category | Status | Priority | +|----------|--------|----------| +| Generator Type | PASS | - | +| Attribute-Based Detection | FAIL | HIGH | +| Model Value Equality | FAIL | HIGH | +| Collection Equality | FAIL | HIGH | +| Symbol/SyntaxNode Storage | PASS | - | +| Code Generation Approach | PASS | - | +| Diagnostics | PASS | - | +| Pipeline Efficiency | FAIL | MEDIUM | +| CancellationToken Handling | PARTIAL | LOW | + +**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`. + +--- + +## Detailed Analysis + +### 1. Generator Interface Selection + +**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:19 +public sealed class ExecutorRouteGenerator : IIncrementalGenerator +``` + +The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators. + +--- + +### 2. Attribute-Based Detection with ForAttributeWithMetadataName + +**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery. + +> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-30 +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) +``` + +**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup. + +**Recommended Fix**: + +```csharp +var executorCandidates = context.SyntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute", + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct)) + .Collect() + .SelectMany((methods, _) => GroupByContainingClass(methods)); +``` + +**Impact**: Current approach causes IDE lag on every keystroke in large projects. + +--- + +### 3. Model Value Equality (Records vs Classes) + +**Best Practice**: Use `record` types for pipeline models to get automatic value equality. + +> "Use `record`s, rather than `class`es, so that value equality is generated for you." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// HandlerInfo.cs:28 +internal sealed class HandlerInfo { ... } + +// ExecutorInfo.cs:10 +internal sealed class ExecutorInfo { ... } +``` + +**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparisonβ€”when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely. + +**Recommended Fix**: + +```csharp +// HandlerInfo.cs +internal sealed record HandlerInfo( + string MethodName, + string InputTypeName, + string? OutputTypeName, + HandlerSignatureKind SignatureKind, + bool HasCancellationToken, + EquatableArray? YieldTypes, + EquatableArray? SendTypes); + +// ExecutorInfo.cs +internal sealed record ExecutorInfo( + string? Namespace, + string ClassName, + string? GenericParameters, + bool IsNested, + string ContainingTypeChain, + bool BaseHasConfigureRoutes, + EquatableArray Handlers, + EquatableArray ClassSendTypes, + EquatableArray ClassYieldTypes); +``` + +**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed. + +--- + +### 4. Collection Equality + +**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray` uses reference equality. + +> "Arrays, `ImmutableArray`, and `List` use reference equality by default. Wrap collections with custom types implementing value-based equality." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorInfo.cs:46 +public ImmutableArray Handlers { get; } + +// HandlerInfo.cs:58-63 +public ImmutableArray? YieldTypes { get; } +public ImmutableArray? SendTypes { get; } +``` + +**Problem**: `ImmutableArray` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching. + +**Recommended Fix**: Create an `EquatableArray` wrapper: + +```csharp +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array; + + public EquatableArray(ImmutableArray array) => _array = array; + + public bool Equals(EquatableArray other) + { + if (_array.Length != other._array.Length) return false; + for (int i = 0; i < _array.Length; i++) + { + if (!_array[i].Equals(other._array[i])) return false; + } + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var item in _array) hash.Add(item); + return hash.ToHashCode(); + } + + // ... IEnumerable implementation +} +``` + +**Impact**: Same as model equalityβ€”caching is completely broken for handlers and type arrays. + +--- + +### 5. Symbol and SyntaxNode Storage + +**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models. + +> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you needβ€”typically string representations work wellβ€”into your equatable models." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +The models correctly store only primitive types and strings: + +```csharp +// HandlerInfo.cs - stores strings, not symbols +public string MethodName { get; } +public string InputTypeName { get; } +public string? OutputTypeName { get; } + +// ExecutorInfo.cs - stores strings, not symbols +public string? Namespace { get; } +public string ClassName { get; } +``` + +The `SemanticAnalyzer` correctly extracts string representations from symbols: + +```csharp +// SemanticAnalyzer.cs:300-301 +var inputType = methodSymbol.Parameters[0].Type; +var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); +``` + +--- + +### 6. Code Generation Approach + +**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction. + +> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +```csharp +// SourceBuilder.cs:17-19 +public static string Generate(ExecutorInfo info) +{ + var sb = new StringBuilder(); +``` + +The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking. + +--- + +### 7. Diagnostic Reporting + +**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:44-50 +context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) => +{ + foreach (var diagnostic in diagnostics) + { + ctx.ReportDiagnostic(diagnostic); + } +}); +``` + +Diagnostics are well-defined with appropriate severities: + +| ID | Severity | Description | +|----|----------|-------------| +| WFGEN001 | Error | Missing IWorkflowContext parameter | +| WFGEN002 | Error | Invalid return type | +| WFGEN003 | Error | Class must be partial | +| WFGEN004 | Warning | Not an Executor | +| WFGEN005 | Error | Insufficient parameters | +| WFGEN006 | Info | ConfigureRoutes already defined | +| WFGEN007 | Error | Handler cannot be static | + +--- + +### 8. Pipeline Efficiency + +**Best Practice**: Avoid duplicate work in the pipeline. + +**Our Implementation**: FAIL (MEDIUM PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-41 +// Pipeline 1: Get executor candidates +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) + ... + +// Pipeline 2: Get diagnostics (duplicates the same work!) +var diagnosticsProvider = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => + { + SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return diagnostics; + }) +``` + +**Problem**: The same syntax detection and semantic analysis runs twiceβ€”once for extracting `ExecutorInfo` and once for extracting diagnostics. + +**Recommended Fix**: Return both in a single pipeline: + +```csharp +var analysisResults = context.SyntaxProvider + .ForAttributeWithMetadataName(...) + .Select((ctx, ct) => { + var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return (Info: info, Diagnostics: diagnostics); + }); + +// Split for different outputs +context.RegisterSourceOutput( + analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!), + GenerateSource); + +context.RegisterSourceOutput( + analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics), + ReportDiagnostics); +``` + +--- + +### 9. Base Type Chain Scanning + +**Best Practice**: Avoid scanning indirect type relationships when possible. + +> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PARTIAL CONCERN + +```csharp +// SemanticAnalyzer.cs:126-141 +private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) +{ + var current = classSymbol.BaseType; + while (current != null) + { + var fullName = current.OriginalDefinition.ToDisplayString(); + if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...)) + { + return true; + } + current = current.BaseType; + } + return false; +} +``` + +**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base typesβ€”which is still targeted. + +--- + +### 10. CancellationToken Handling + +**Best Practice**: Respect `CancellationToken` in long-running operations. + +**Our Implementation**: PARTIAL (LOW PRIORITY) + +The `CancellationToken` is passed through to semantic model calls: + +```csharp +// SemanticAnalyzer.cs:46 +var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken); +``` + +However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks. + +--- + +### 11. File Naming Convention + +**Best Practice**: Use descriptive generated file names with `.g.cs` suffix. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:62-91 +private static string GetHintName(ExecutorInfo info) +{ + // Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs" + ... + sb.Append(".g.cs"); + return sb.ToString(); +} +``` + +--- + +## Recommended Action Plan + +### High Priority (Performance Critical) + +1. **Switch to `ForAttributeWithMetadataName`** + - Estimated impact: 99x+ performance improvement for attribute detection + - Requires restructuring the pipeline to collect methods then group by class + +2. **Convert models to records** + - Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record` + - Enables automatic value equality for incremental caching + +3. **Implement `EquatableArray`** + - Create wrapper struct with value-based equality + - Replace all `ImmutableArray` usages in models + +### Medium Priority (Efficiency) + +4. **Eliminate duplicate pipeline execution** + - Combine info extraction and diagnostic collection into single pipeline + - Split outputs using `Where` and `Select` + +### Low Priority (Polish) + +5. **Add periodic cancellation checks** + - Add `ThrowIfCancellationRequested()` in handler analysis loop + - Only needed for extremely large classes + +--- + +## Compliance Matrix + +| Best Practice | Cookbook Reference | Status | Fix Required | +|--------------|-------------------|--------|--------------| +| Use IIncrementalGenerator | Main cookbook | PASS | No | +| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) | +| Use records for models | Incremental cookbook | FAIL | Yes (High) | +| Implement collection equality | Incremental cookbook | FAIL | Yes (High) | +| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No | +| Use StringBuilder for codegen | Incremental cookbook | PASS | No | +| Report diagnostics properly | Main cookbook | PASS | No | +| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) | +| Respect CancellationToken | Main cookbook | PARTIAL | Optional | +| Use .g.cs file suffix | Main cookbook | PASS | No | +| Additive-only generation | Main cookbook | PASS | No | +| No language feature emulation | Main cookbook | PASS | No | + +--- + +## Conclusion + +The source generator implementation demonstrates solid understanding of Roslyn generator fundamentalsβ€”correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments. + +The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes." diff --git a/dotnet/wf-source-gen-changes.md b/dotnet/wf-source-gen-changes.md new file mode 100644 index 0000000000..cc0aca5157 --- /dev/null +++ b/dotnet/wf-source-gen-changes.md @@ -0,0 +1,258 @@ +# Workflow Executor Route Source Generator - Implementation Summary + +This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor` pattern with compile-time code generation using `[MessageHandler]` attributes. + +## Overview + +The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor`. + +## New Files Created + +### Attributes (3 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays | +| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send | +| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield | + +### Source Generator Project (8 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings | +| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction | +| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic | +| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions | + +## Files Modified + +### Project Files + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests | +| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 | +| `agent-framework-dotnet.slnx` | Added generator project to solution | + +### Obsolete Annotations + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance | +| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler` and `IMessageHandler` interfaces | + +### Pragma Suppressions for Internal Obsolete Usage + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` | + +### Test File Pragma Suppressions + +| File | Changes | +|------|---------| +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | + +## Attribute Definitions + +### MessageHandlerAttribute + +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +### SendsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +### YieldsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +## Diagnostic Rules + +| ID | Severity | Description | +|----|----------|-------------| +| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) | +| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext | +| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask | +| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial | +| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) | +| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored | +| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken | + +## Handler Signature Support + +The generator supports the following method signatures: + +| Return Type | Parameters | Generated Call | +|-------------|------------|----------------| +| `void` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | + +## Generated Code Example + +### Input (User Code) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : Executor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + return new ChatResponse(...); + } + + [MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Handler implementation + } +} +``` + +### Output (Generated Code) + +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); + types.Add(typeof(StreamChunk)); + return types; + } +} +``` + +## Build Issues Resolved + +### 1. NU1008 - Central Package Management +Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items. + +### 2. RS2008 - Analyzer Release Tracking +Roslyn requires analyzer release tracking documentation. Fixed by adding `$(NoWarn);RS2008` to the generator project. + +### 3. CA1068 - CancellationToken Parameter Order +Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last. + +### 4. RCS1146 - Conditional Access +Used null check with `&&` instead of `?.` operator. Fixed by using conditional access. + +### 5. CA1310 - StringComparison +`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`. + +### 6. CS0103 - Missing Using Directive +Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive. + +### 7. CS0618 - Obsolete Warnings as Errors +Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files. + +### 8. NU1109 - Package Version Conflict +`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`. + +### 9. RS1041 - Wrong Target Framework for Analyzer +The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`. + +## Migration Guide + +### Before (Reflection-based) + +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public MyExecutor() : base("MyExecutor") { } + + public ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +### After (Source Generator) + +```csharp +public partial class MyExecutor : Executor +{ + public MyExecutor() : base("MyExecutor") { } + + [MessageHandler] + private ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +Key migration steps: +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Remove `IMessageHandler` interface implementations +4. Add `[MessageHandler]` attribute to handler methods +5. Handler methods can now be any accessibility (private, protected, internal, public) + +## Future Work + +- Create comprehensive unit tests for the source generator +- Add integration tests verifying generated routes match reflection-discovered routes +- Consider adding IDE quick-fix for migrating from `ReflectingExecutor` pattern diff --git a/wf-source-gen-plan.md b/wf-source-gen-plan.md new file mode 100644 index 0000000000..e936b538b2 --- /dev/null +++ b/wf-source-gen-plan.md @@ -0,0 +1,293 @@ +# Roslyn Source Generator for Workflow Executor Routes + +## Overview + +Replace the reflection-based `ReflectingExecutor` pattern with a compile-time source generator that discovers `[MessageHandler]` attributed methods and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` implementations. + +## Design Decisions (Confirmed) + +- **Attribute syntax**: Inline properties on `[MessageHandler(Yield=[...], Send=[...])]` +- **Class-level attributes**: Generate `ConfigureSentTypes()`/`ConfigureYieldTypes()` from `[SendsMessage]`/`[YieldsMessage]` +- **Migration**: Clean break - requires direct `Executor` inheritance (not `ReflectingExecutor`) +- **Handler accessibility**: Any (private, protected, internal, public) + +--- + +## Implementation Steps + +### Phase 1: Create Source Generator Project + +**1.1 Create project structure:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ +β”œβ”€β”€ Microsoft.Agents.AI.Workflows.Generators.csproj +β”œβ”€β”€ ExecutorRouteGenerator.cs # Main incremental generator +β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ ExecutorInfo.cs # Data model for executor analysis +β”‚ └── HandlerInfo.cs # Data model for handler methods +β”œβ”€β”€ Analysis/ +β”‚ β”œβ”€β”€ SyntaxDetector.cs # Syntax-based candidate detection +β”‚ └── SemanticAnalyzer.cs # Semantic model analysis +β”œβ”€β”€ Generation/ +β”‚ └── SourceBuilder.cs # Code generation logic +└── Diagnostics/ + └── DiagnosticDescriptors.cs # Analyzer diagnostics +``` + +**1.2 Project file configuration:** +- Target `netstandard2.0` +- Reference `Microsoft.CodeAnalysis.CSharp` 4.8.0+ +- Set `IsRoslynComponent=true`, `EnforceExtendedAnalyzerRules=true` +- Package as analyzer in `analyzers/dotnet/cs` + +### Phase 2: Define Attributes + +**2.1 Create `MessageHandlerAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +**2.2 Create `SendsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = type; +} +``` + +**2.3 Create `YieldsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = type; +} +``` + +### Phase 3: Implement Source Generator + +**3.1 Detection criteria (syntax level):** +- Class has `partial` modifier +- Class has at least one method with `[MessageHandler]` attribute + +**3.2 Validation criteria (semantic level):** +- Class derives from `Executor` (directly or transitively) +- Class does NOT already define `ConfigureRoutes` with a body +- Handler method has valid signature: `(TMessage, IWorkflowContext[, CancellationToken])` +- Handler returns `void`, `ValueTask`, or `ValueTask` + +**3.3 Handler signature mapping:** + +| Method Signature | Generated AddHandler Call | +|-----------------|---------------------------| +| `void Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `void Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `TResult Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | + +**3.4 Generated code structure:** +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + // Call base if inheriting from another executor with routes + // routeBuilder = base.ConfigureRoutes(routeBuilder); + + return routeBuilder + .AddHandler(this.Handler1) + .AddHandler(this.Handler2); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(SentType1)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(YieldType1)); + return types; + } +} +``` + +**3.5 Inheritance handling:** + +| Scenario | Generated `ConfigureRoutes` | +|----------|----------------------------| +| Directly extends `Executor` | No base call (abstract) | +| Extends executor with `[MessageHandler]` methods | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | +| Extends executor with manual `ConfigureRoutes` | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | + +### Phase 4: Analyzer Diagnostics + +| ID | Severity | Condition | +|----|----------|-----------| +| `WFGEN001` | Error | Handler missing `IWorkflowContext` parameter | +| `WFGEN002` | Error | Handler has invalid return type | +| `WFGEN003` | Error | Executor with `[MessageHandler]` must be `partial` | +| `WFGEN004` | Warning | `[MessageHandler]` on non-Executor class | +| `WFGEN005` | Error | Handler has fewer than 2 parameters | +| `WFGEN006` | Info | `ConfigureRoutes` already defined, handlers ignored | + +### Phase 5: Integration & Migration + +**5.1 Wire generator to main project:** +```xml + + + + +``` + +**5.2 Mark `ReflectingExecutor` obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "See migration guide. This type will be removed in v1.0.", error: false)] +public class ReflectingExecutor : Executor ... +``` + +**5.3 Mark `IMessageHandler` interfaces obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute instead.")] +public interface IMessageHandler { ... } +``` + +### Phase 6: Testing + +**6.1 Generator unit tests:** +``` +dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ +β”œβ”€β”€ ExecutorRouteGeneratorTests.cs +β”œβ”€β”€ SyntaxDetectorTests.cs +β”œβ”€β”€ SemanticAnalyzerTests.cs +└── TestHelpers/ + └── GeneratorTestHelper.cs +``` + +Test cases: +- Simple single handler +- Multiple handlers on one class +- Handlers with different signatures (void, ValueTask, ValueTask) +- Nested classes +- Generic executors +- Inheritance chains (Executor -> CustomBase -> Concrete) +- Class-level `[SendsMessage]`/`[YieldsMessage]` attributes +- Manual `ConfigureRoutes` present (should skip generation) +- Invalid signatures (should produce diagnostics) + +**6.2 Integration tests:** +- Port existing `ReflectingExecutor` test cases to use `[MessageHandler]` +- Verify generated routes match reflection-discovered routes + +--- + +## Files to Create + +| Path | Purpose | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Generator project | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main generator | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Syntax analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code gen | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Diagnostics | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Handler attribute | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level send | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level yield | +| `dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/*.cs` | Generator tests | + +## Files to Modify + +| Path | Changes | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Add generator reference | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Add `[Obsolete]` | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Add `[Obsolete]` | +| `dotnet/Microsoft.Agents.sln` | Add new projects | + +--- + +## Example Usage (End State) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : ChatProtocolExecutor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + // Return type automatically inferred as output + return new ChatResponse(...); + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Explicit Yield/Send for complex handlers + } +} +``` + +Generated: +```csharp +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + routeBuilder = base.ConfigureRoutes(routeBuilder); + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); // From handler attribute + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); // From return type + types.Add(typeof(StreamChunk)); // From handler attribute + return types; + } +} +``` From 5e7d19c48ee23ca2a5d04e85a9e8542aabc97075 Mon Sep 17 00:00:00 2001 From: alliscode Date: Wed, 7 Jan 2026 16:36:17 -0800 Subject: [PATCH 02/13] Updates to validate class data once instead of for each handler method. Also updated Diagnostics Ids to format of MAFGENWF{NUM} --- .../Analysis/SemanticAnalyzer.cs | 117 ++++++++++-------- .../Diagnostics/DiagnosticDescriptors.cs | 28 ++--- .../ExecutorRouteGenerator.cs | 4 +- .../Models/AnalysisResult.cs | 6 +- .../Models/DiagnosticInfo.cs | 61 +++++++-- .../Models/EquatableArray.cs | 28 ++--- .../Models/ExecutorInfo.cs | 14 +-- .../Models/HandlerInfo.cs | 4 +- .../Models/MethodAnalysisResult.cs | 12 +- .../ExecutorRouteGeneratorTests.cs | 19 +-- 10 files changed, 179 insertions(+), 114 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index aea56b4503..345d966559 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -30,12 +30,15 @@ internal static class SemanticAnalyzer /// Analyzes a method with [MessageHandler] attribute found by ForAttributeWithMetadataName. /// Returns a MethodAnalysisResult containing both method info and class context. /// + /// + /// This method only extracts raw data and performs method-level validation. + /// Class-level validation is deferred to to avoid + /// redundant validation when a class has multiple handler methods. + /// public static MethodAnalysisResult AnalyzeMethod( GeneratorAttributeSyntaxContext context, CancellationToken cancellationToken) { - var diagnostics = ImmutableArray.CreateBuilder(); - // The target should be a method if (context.TargetSymbol is not IMethodSymbol methodSymbol) { @@ -52,7 +55,7 @@ public static MethodAnalysisResult AnalyzeMethod( // Get the method syntax for location info var methodSyntax = context.TargetNode as MethodDeclarationSyntax; - // Extract class-level info + // Extract class-level info (raw facts, no validation here) var classKey = GetClassKey(classSymbol); var isPartialClass = IsPartialClass(classSymbol, cancellationToken); var derivesFromExecutor = DerivesFromExecutor(classSymbol); @@ -70,36 +73,30 @@ public static MethodAnalysisResult AnalyzeMethod( var classSendTypes = GetClassLevelTypes(classSymbol, SendsMessageAttributeName); var classYieldTypes = GetClassLevelTypes(classSymbol, YieldsMessageAttributeName); - // Validate class-level requirements and collect diagnostics - if (!derivesFromExecutor) + // Get class location for class-level diagnostics + var 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(); + HandlerInfo? handler = null; + if (derivesFromExecutor) { - diagnostics.Add(DiagnosticInfo.Create( - "WFGEN004", - methodSyntax?.Identifier.GetLocation() ?? context.TargetNode.GetLocation(), - methodSymbol.Name, - classSymbol.Name)); - - return new MethodAnalysisResult( - classKey, @namespace, className, genericParameters, isNested, containingTypeChain, - baseHasConfigureRoutes, classSendTypes, classYieldTypes, - isPartialClass, derivesFromExecutor, hasManualConfigureRoutes, - Handler: null, - Diagnostics: new EquatableArray(diagnostics.ToImmutable())); + handler = AnalyzeHandler(methodSymbol, methodSyntax, methodDiagnostics); } - // Analyze the handler method - var handler = AnalyzeHandler(methodSymbol, methodSyntax, diagnostics); - return new MethodAnalysisResult( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, baseHasConfigureRoutes, classSendTypes, classYieldTypes, isPartialClass, derivesFromExecutor, hasManualConfigureRoutes, + classLocation, handler, - Diagnostics: new EquatableArray(diagnostics.ToImmutable())); + Diagnostics: new EquatableArray(methodDiagnostics.ToImmutable())); } /// /// Combines multiple MethodAnalysisResults for the same class into an AnalysisResult. + /// Performs class-level validation once (instead of per-method) for efficiency. /// public static AnalysisResult CombineMethodResults(IEnumerable methodResults) { @@ -111,38 +108,45 @@ public static AnalysisResult CombineMethodResults(IEnumerable m.Diagnostics) - .ToImmutableArray(); + // Collect method-level diagnostics + var allDiagnostics = ImmutableArray.CreateBuilder(); + foreach (var method in methods) + { + foreach (var diag in method.Diagnostics) + { + allDiagnostics.Add(diag.ToDiagnostic(null)); + } + } - // Check class-level validation + // Class-level validation (done once, not per-method) if (!first.DerivesFromExecutor) { - // Diagnostics already added per-method - return AnalysisResult.WithDiagnostics( - allDiagnostics.Select(d => d.ToDiagnostic(null)).ToImmutableArray()); + allDiagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.NotAnExecutor, + classLocation, + first.ClassName, + first.ClassName)); + return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } if (!first.IsPartialClass) { - var diag = Diagnostic.Create( + allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ClassMustBePartial, - Location.None, // We don't have class location easily accessible here - first.ClassName); - return AnalysisResult.WithDiagnostics( - allDiagnostics.Select(d => d.ToDiagnostic(null)).Append(diag).ToImmutableArray()); + classLocation, + first.ClassName)); + return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } if (first.HasManualConfigureRoutes) { - var diag = Diagnostic.Create( + allDiagnostics.Add(Diagnostic.Create( DiagnosticDescriptors.ConfigureRoutesAlreadyDefined, - Location.None, - first.ClassName); - return AnalysisResult.WithDiagnostics( - allDiagnostics.Select(d => d.ToDiagnostic(null)).Append(diag).ToImmutableArray()); + classLocation, + first.ClassName)); + return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } // Collect valid handlers @@ -153,8 +157,7 @@ public static AnalysisResult CombineMethodResults(IEnumerable d.ToDiagnostic(null)).ToImmutableArray()); + return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable()); } var executorInfo = new ExecutorInfo( @@ -168,11 +171,9 @@ public static AnalysisResult CombineMethodResults(IEnumerable 0) + if (allDiagnostics.Count > 0) { - return AnalysisResult.WithInfoAndDiagnostics( - executorInfo, - allDiagnostics.Select(d => d.ToDiagnostic(null)).ToImmutableArray()); + return AnalysisResult.WithInfoAndDiagnostics(executorInfo, allDiagnostics.ToImmutable()); } return AnalysisResult.Success(executorInfo); @@ -184,7 +185,21 @@ private static MethodAnalysisResult CreateEmptyResult() string.Empty, null, string.Empty, null, false, string.Empty, false, EquatableArray.Empty, EquatableArray.Empty, false, false, false, - null, EquatableArray.Empty); + null, null, EquatableArray.Empty); + } + + private static LocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) + { + foreach (var syntaxRef in classSymbol.DeclaringSyntaxReferences) + { + var syntax = syntaxRef.GetSyntax(cancellationToken); + if (syntax is ClassDeclarationSyntax classDecl) + { + return LocationInfo.FromLocation(classDecl.Identifier.GetLocation()); + } + } + + return null; } private static string GetClassKey(INamedTypeSymbol classSymbol) @@ -273,14 +288,14 @@ private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol) // Check if static if (methodSymbol.IsStatic) { - diagnostics.Add(DiagnosticInfo.Create("WFGEN007", location, methodSymbol.Name)); + diagnostics.Add(DiagnosticInfo.Create("MAFGENWF007", location, methodSymbol.Name)); return null; } // Check parameter count if (methodSymbol.Parameters.Length < 2) { - diagnostics.Add(DiagnosticInfo.Create("WFGEN005", location, methodSymbol.Name)); + diagnostics.Add(DiagnosticInfo.Create("MAFGENWF005", location, methodSymbol.Name)); return null; } @@ -288,7 +303,7 @@ private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol) var secondParam = methodSymbol.Parameters[1]; if (secondParam.Type.ToDisplayString() != WorkflowContextTypeName) { - diagnostics.Add(DiagnosticInfo.Create("WFGEN001", location, methodSymbol.Name)); + diagnostics.Add(DiagnosticInfo.Create("MAFGENWF001", location, methodSymbol.Name)); return null; } @@ -301,7 +316,7 @@ private static bool BaseHasConfigureRoutes(INamedTypeSymbol classSymbol) var signatureKind = GetSignatureKind(returnType); if (signatureKind == null) { - diagnostics.Add(DiagnosticInfo.Create("WFGEN002", location, methodSymbol.Name)); + diagnostics.Add(DiagnosticInfo.Create("MAFGENWF002", location, methodSymbol.Name)); return null; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs index 161b1e82ec..cd61c8b13d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -29,10 +29,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) } /// - /// WFGEN001: Handler method must have IWorkflowContext parameter. + /// MAFGENWF001: Handler method must have IWorkflowContext parameter. /// public static readonly DiagnosticDescriptor MissingWorkflowContext = Register(new( - id: "WFGEN001", + id: "MAFGENWF001", title: "Handler missing IWorkflowContext parameter", messageFormat: "Method '{0}' marked with [MessageHandler] must have IWorkflowContext as the second parameter", category: Category, @@ -40,10 +40,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN002: Handler method has invalid return type. + /// MAFGENWF002: Handler method has invalid return type. /// public static readonly DiagnosticDescriptor InvalidReturnType = Register(new( - id: "WFGEN002", + id: "MAFGENWF002", title: "Handler has invalid return type", messageFormat: "Method '{0}' marked with [MessageHandler] must return void, ValueTask, or ValueTask", category: Category, @@ -51,10 +51,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN003: Executor with [MessageHandler] must be partial. + /// MAFGENWF003: Executor with [MessageHandler] must be partial. /// public static readonly DiagnosticDescriptor ClassMustBePartial = Register(new( - id: "WFGEN003", + id: "MAFGENWF003", title: "Executor with [MessageHandler] must be partial", messageFormat: "Class '{0}' contains [MessageHandler] methods but is not declared as partial", category: Category, @@ -62,10 +62,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN004: [MessageHandler] on non-Executor class. + /// MAFGENWF004: [MessageHandler] on non-Executor class. /// public static readonly DiagnosticDescriptor NotAnExecutor = Register(new( - id: "WFGEN004", + 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, @@ -73,10 +73,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN005: Handler method has insufficient parameters. + /// MAFGENWF005: Handler method has insufficient parameters. /// public static readonly DiagnosticDescriptor InsufficientParameters = Register(new( - id: "WFGEN005", + id: "MAFGENWF005", title: "Handler has insufficient parameters", messageFormat: "Method '{0}' marked with [MessageHandler] must have at least 2 parameters (message and IWorkflowContext)", category: Category, @@ -84,10 +84,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN006: ConfigureRoutes already defined. + /// MAFGENWF006: ConfigureRoutes already defined. /// public static readonly DiagnosticDescriptor ConfigureRoutesAlreadyDefined = Register(new( - id: "WFGEN006", + id: "MAFGENWF006", title: "ConfigureRoutes already defined", messageFormat: "Class '{0}' already defines ConfigureRoutes; [MessageHandler] methods will be ignored", category: Category, @@ -95,10 +95,10 @@ private static DiagnosticDescriptor Register(DiagnosticDescriptor descriptor) isEnabledByDefault: true)); /// - /// WFGEN007: Handler method is static. + /// MAFGENWF007: Handler method is static. /// public static readonly DiagnosticDescriptor HandlerCannotBeStatic = Register(new( - id: "WFGEN007", + id: "MAFGENWF007", title: "Handler cannot be static", messageFormat: "Method '{0}' marked with [MessageHandler] cannot be static", category: Category, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs index 598353e5e3..d5c82e596e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs @@ -1,6 +1,5 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. -using System.Collections.Immutable; using System.Linq; using System.Text; using Microsoft.Agents.AI.Workflows.Generators.Analysis; @@ -26,7 +25,6 @@ public sealed class ExecutorRouteGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { // Step 1: Use ForAttributeWithMetadataName to efficiently find methods with [MessageHandler] - // This is 99x+ more efficient than CreateSyntaxProvider according to the Roslyn cookbook var methodAnalysisResults = context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: MessageHandlerAttributeFullName, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs index 3576e903d2..b0e17d3247 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System.Collections.Immutable; using Microsoft.CodeAnalysis; @@ -18,8 +18,8 @@ internal sealed class AnalysisResult public AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray diagnostics) { - ExecutorInfo = executorInfo; - Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + this.ExecutorInfo = executorInfo; + this.Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs index b76c723f64..7451ae5aa7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Workflows.Generators.Diagnostics; using Microsoft.CodeAnalysis; @@ -6,6 +6,45 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; +/// +/// Represents location information in a form that supports value equality. +/// +internal sealed record LocationInfo( + string FilePath, + TextSpan Span, + LinePositionSpan LineSpan) +{ + /// + /// Creates a LocationInfo from a Roslyn Location. + /// + public static LocationInfo? FromLocation(Location? location) + { + if (location is null || location == Location.None) + { + return null; + } + + var lineSpan = location.GetLineSpan(); + return new LocationInfo( + lineSpan.Path ?? string.Empty, + location.SourceSpan, + lineSpan.Span); + } + + /// + /// Converts back to a Roslyn Location. + /// + public Location ToLocation() + { + if (string.IsNullOrEmpty(this.FilePath)) + { + return Location.None; + } + + return Location.Create(this.FilePath, this.Span, this.LineSpan); + } +} + /// /// 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. @@ -36,14 +75,14 @@ public static DiagnosticInfo Create(string diagnosticId, Location location, para /// public Diagnostic ToDiagnostic(SyntaxTree? syntaxTree) { - var descriptor = DiagnosticDescriptors.GetById(DiagnosticId); + var descriptor = DiagnosticDescriptors.GetById(this.DiagnosticId); if (descriptor is null) { // Fallback - should not happen - var fallbackArgs = new object[MessageArgs.Length]; - for (int i = 0; i < MessageArgs.Length; i++) + var fallbackArgs = new object[this.MessageArgs.Length]; + for (int i = 0; i < this.MessageArgs.Length; i++) { - fallbackArgs[i] = MessageArgs[i]; + fallbackArgs[i] = this.MessageArgs[i]; } return Diagnostic.Create( @@ -55,21 +94,21 @@ public Diagnostic ToDiagnostic(SyntaxTree? syntaxTree) Location location; if (syntaxTree is not null) { - location = Location.Create(syntaxTree, Span); + location = Location.Create(syntaxTree, this.Span); } - else if (!string.IsNullOrEmpty(FilePath)) + else if (!string.IsNullOrEmpty(this.FilePath)) { - location = Location.Create(FilePath, Span, LineSpan); + location = Location.Create(this.FilePath, this.Span, this.LineSpan); } else { location = Location.None; } - var args = new object[MessageArgs.Length]; - for (int i = 0; i < MessageArgs.Length; i++) + var args = new object[this.MessageArgs.Length]; + for (int i = 0; i < this.MessageArgs.Length; i++) { - args[i] = MessageArgs[i]; + args[i] = this.MessageArgs[i]; } return Diagnostic.Create(descriptor, location, args); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs index 32122152a0..c307e7d733 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections; @@ -21,40 +21,40 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// public EquatableArray(ImmutableArray array) { - _array = array.IsDefault ? ImmutableArray.Empty : array; + this._array = array.IsDefault ? ImmutableArray.Empty : array; } /// /// Gets the underlying array. /// - public ImmutableArray AsImmutableArray() => _array; + public ImmutableArray AsImmutableArray() => this._array; /// /// Gets the number of elements in the array. /// - public int Length => _array.Length; + public int Length => this._array.Length; /// /// Gets the element at the specified index. /// - public T this[int index] => _array[index]; + public T this[int index] => this._array[index]; /// /// Gets whether the array is empty. /// - public bool IsEmpty => _array.IsEmpty; + public bool IsEmpty => this._array.IsEmpty; /// public bool Equals(EquatableArray other) { - if (_array.Length != other._array.Length) + if (this._array.Length != other._array.Length) { return false; } - for (int i = 0; i < _array.Length; i++) + for (int i = 0; i < this._array.Length; i++) { - if (!_array[i].Equals(other._array[i])) + if (!this._array[i].Equals(other._array[i])) { return false; } @@ -66,19 +66,19 @@ public bool Equals(EquatableArray other) /// public override bool Equals(object? obj) { - return obj is EquatableArray other && Equals(other); + return obj is EquatableArray other && this.Equals(other); } /// public override int GetHashCode() { - if (_array.IsEmpty) + if (this._array.IsEmpty) { return 0; } var hashCode = 17; - foreach (var item in _array) + foreach (var item in this._array) { hashCode = hashCode * 31 + item?.GetHashCode() ?? 0; } @@ -89,13 +89,13 @@ public override int GetHashCode() /// public IEnumerator GetEnumerator() { - return ((IEnumerable)_array).GetEnumerator(); + return ((IEnumerable)this._array).GetEnumerator(); } /// IEnumerator IEnumerable.GetEnumerator() { - return GetEnumerator(); + return this.GetEnumerator(); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs index 9d1a036613..c25f304409 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Workflows.Generators.Models; @@ -30,10 +30,10 @@ internal sealed record ExecutorInfo( /// Gets whether any protocol type overrides should be generated. /// public bool ShouldGenerateProtocolOverrides => - !ClassSendTypes.IsEmpty || - !ClassYieldTypes.IsEmpty || - HasHandlerWithSendTypes || - HasHandlerWithYieldTypes; + !this.ClassSendTypes.IsEmpty || + !this.ClassYieldTypes.IsEmpty || + this.HasHandlerWithSendTypes || + this.HasHandlerWithYieldTypes; /// /// Gets whether any handler has explicit Send types. @@ -42,7 +42,7 @@ public bool HasHandlerWithSendTypes { get { - foreach (var handler in Handlers) + foreach (var handler in this.Handlers) { if (!handler.SendTypes.IsEmpty) { @@ -61,7 +61,7 @@ public bool HasHandlerWithYieldTypes { get { - foreach (var handler in Handlers) + foreach (var handler in this.Handlers) { if (!handler.YieldTypes.IsEmpty) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs index 0cae0f2ba4..28fd441259 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Workflows.Generators.Models; @@ -43,5 +43,5 @@ internal sealed record HandlerInfo( /// /// Gets whether this handler returns a value (either sync or async). /// - public bool HasOutput => SignatureKind == HandlerSignatureKind.ResultSync || SignatureKind == HandlerSignatureKind.ResultAsync; + public bool HasOutput => this.SignatureKind == HandlerSignatureKind.ResultSync || this.SignatureKind == HandlerSignatureKind.ResultAsync; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index 7e1e2f4fb7..ea2c9723f1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -7,6 +7,11 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Contains both the method's handler info and class context for grouping. /// Uses value-equatable types to support incremental generator caching. /// +/// +/// 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. +/// internal sealed record MethodAnalysisResult( // Class identification for grouping string ClassKey, @@ -21,13 +26,16 @@ internal sealed record MethodAnalysisResult( EquatableArray ClassSendTypes, EquatableArray ClassYieldTypes, - // Class-level validation results + // Class-level facts (used for validation in CombineMethodResults) bool IsPartialClass, bool DerivesFromExecutor, bool HasManualConfigureRoutes, + // Class location for diagnostics (value-equatable) + LocationInfo? ClassLocation, + // Method-level info (null if method validation failed) HandlerInfo? Handler, - // Any diagnostics from analyzing this method (uses DiagnosticInfo for value equality) + // Method-level diagnostics only (class-level diagnostics created in CombineMethodResults) EquatableArray Diagnostics); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs index a846bf4355..c5261ed4d3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -343,7 +343,7 @@ private void HandleMessage(string message, IWorkflowContext context) { } #region Diagnostic Tests [Fact] - public void NonPartialClass_ProducesDiagnostic() + public void NonPartialClass_ProducesDiagnosticAndNoSource() { var source = """ using System.Threading; @@ -363,7 +363,12 @@ private void HandleMessage(string message, IWorkflowContext context) { } var result = GeneratorTestHelper.RunGenerator(source); - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN003"); + // Should produce MAFGENWF003 diagnostic + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF003"); + + // Should NOT generate any source (to avoid CS0260) + result.RunResult.GeneratedTrees.Should().BeEmpty( + "non-partial classes should not have source generated to avoid CS0260 compiler error"); } [Fact] @@ -385,7 +390,7 @@ private void HandleMessage(string message, IWorkflowContext context) { } var result = GeneratorTestHelper.RunGenerator(source); - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN004"); + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF004"); } [Fact] @@ -409,7 +414,7 @@ private static void HandleMessage(string message, IWorkflowContext context) { } var result = GeneratorTestHelper.RunGenerator(source); - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN007"); + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF007"); } [Fact] @@ -433,7 +438,7 @@ private void HandleMessage(string message) { } var result = GeneratorTestHelper.RunGenerator(source); - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN005"); + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF005"); } [Fact] @@ -457,7 +462,7 @@ private void HandleMessage(string message, string notContext) { } var result = GeneratorTestHelper.RunGenerator(source); - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN001"); + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF001"); } #endregion @@ -491,7 +496,7 @@ private void HandleMessage(string message, IWorkflowContext context) { } var result = GeneratorTestHelper.RunGenerator(source); // Should produce diagnostic but not generate code - result.RunResult.Diagnostics.Should().Contain(d => d.Id == "WFGEN006"); + result.RunResult.Diagnostics.Should().Contain(d => d.Id == "MAFGENWF006"); result.RunResult.GeneratedTrees.Should().BeEmpty(); } From 6aa33f774725391301c213ff7a8cdaaa52d5fa8b Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 08:14:44 -0800 Subject: [PATCH 03/13] Formatting and trying to fix generation project pack. --- .../Diagnostics/DiagnosticDescriptors.cs | 2 +- .../Generation/SourceBuilder.cs | 2 +- .../Microsoft.Agents.AI.Workflows.Generators.csproj | 11 +++++------ .../Models/AnalysisResult.cs | 2 +- .../Models/MethodAnalysisResult.cs | 2 +- .../Attributes/MessageHandlerAttribute.cs | 2 +- .../Attributes/SendsMessageAttribute.cs | 2 +- .../Attributes/YieldsMessageAttribute.cs | 2 +- 8 files changed, 12 insertions(+), 13 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs index cd61c8b13d..4afc7a1697 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.CodeAnalysis; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs index 89434b7027..d2f4ec6805 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Text; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index 1f82a3ba73..4768975f70 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -14,7 +14,11 @@ true true - false + + + true + analyzers/dotnet/cs + true $(NoWarn);nullable @@ -40,9 +44,4 @@ - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs index b0e17d3247..7cab18c70b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Represents the result of analyzing a class with [MessageHandler] methods. /// Combines the executor info (if valid) with any diagnostics to report. /// Note: This type is used after the caching layer (in RegisterSourceOutput), -/// so it can contain Diagnostic objects directly. +/// so it should not cause issue to contain Diagnostic objects directly. /// internal sealed class AnalysisResult { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index ea2c9723f1..4febe9d8c9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Workflows.Generators.Models; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs index 14b413d4e2..7f40b3573d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs index 53b758144c..3b5620fc37 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; using Microsoft.Shared.Diagnostics; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs index e79baa3ebf..82ca9106b7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; using Microsoft.Shared.Diagnostics; From 5746cbce8a07c29c82caf9dad2b03c2853b47190 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 09:34:05 -0800 Subject: [PATCH 04/13] Another atempt at getting the genrators project to build. --- ...soft.Agents.AI.Workflows.Generators.csproj | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index 4768975f70..7a71f439a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -1,10 +1,31 @@ - + + + + <_SkipIncompatibleBuild>true + + true + + + + + + - - - netstandard2.0 + netstandard2.0 + + + + + false + false + true + false + false + + + latest enable @@ -39,9 +60,36 @@ true - + + + + + + + + true + false + + + true + + true + false + false + + + + + + + + + + + + From 69e1fb070c7bfe44d5c8485130ebbfa8117e76b5 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 11:10:40 -0800 Subject: [PATCH 05/13] More attempts to fix generator build and pack. --- dotnet/agent-framework-dotnet.slnx | 3 +- ...soft.Agents.AI.Workflows.Generators.csproj | 61 +++---------------- 2 files changed, 11 insertions(+), 53 deletions(-) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 01693a29ce..78c6c24fd1 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -396,7 +396,7 @@ - + @@ -436,6 +436,7 @@ + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index 7a71f439a4..e3e6416087 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -1,31 +1,11 @@ - - - - - <_SkipIncompatibleBuild>true - - true - - - - + netstandard2.0 - netstandard2.0 - - - false - false - true - false - false - - - + latest enable @@ -60,36 +40,13 @@ true - - - + + + + - - - - - - - true - false - - - true - - true - false - false - - - - - - - - - - - - From eef026075f9ee123795c0cf05f98146e17781653 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 11:28:39 -0800 Subject: [PATCH 06/13] Fixing file encodings. --- .../ExecutorRouteGeneratorTests.cs | 2 +- .../GeneratorTestHelper.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs index c5261ed4d3..c0d617d9c2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using FluentAssertions; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs index 2a6342231a..19ad9827be 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/GeneratorTestHelper.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +ο»Ώ// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; From cab267201db402d64a78b5e7c2d25b2ed7aaf9d6 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 14:34:10 -0800 Subject: [PATCH 07/13] Initail round of cleanup. --- dotnet/agent-framework-dotnet.slnx | 2 +- .../Analysis/SemanticAnalyzer.cs | 8 ++-- .../Directory.Build.targets | 18 ++++++++ .../ExecutorRouteGenerator.cs | 21 +++++---- .../Models/AnalysisResult.cs | 21 +++++---- .../Models/DiagnosticInfo.cs | 43 +----------------- .../Models/DiagnosticLocationInfo.cs | 45 +++++++++++++++++++ .../Models/EquatableArray.cs | 17 +++---- .../Models/MethodAnalysisResult.cs | 2 +- .../SkipIncompatibleBuild.targets | 10 +++++ 10 files changed, 111 insertions(+), 76 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 78c6c24fd1..9e210a7062 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -396,7 +396,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index 345d966559..a87b97bd07 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -108,7 +108,7 @@ public static AnalysisResult CombineMethodResults(IEnumerable(); @@ -116,7 +116,7 @@ public static AnalysisResult CombineMethodResults(IEnumerable.Empty); } - private static LocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) + private static DiagnosticLocationInfo? GetClassLocation(INamedTypeSymbol classSymbol, CancellationToken cancellationToken) { foreach (var syntaxRef in classSymbol.DeclaringSyntaxReferences) { var syntax = syntaxRef.GetSyntax(cancellationToken); if (syntax is ClassDeclarationSyntax classDecl) { - return LocationInfo.FromLocation(classDecl.Identifier.GetLocation()); + return DiagnosticLocationInfo.FromLocation(classDecl.Identifier.GetLocation()); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets new file mode 100644 index 0000000000..7fc2764c90 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets @@ -0,0 +1,18 @@ + + + + <_ParentTargetsPath>$([MSBuild]::GetPathOfFileAbove(Directory.Build.targets, $(MSBuildThisFileDirectory)..)) + + + + + + <_SkipIncompatibleBuild>true + + + true + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs index d5c82e596e..8bdcbf3d81 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs @@ -24,7 +24,7 @@ public sealed class ExecutorRouteGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - // Step 1: Use ForAttributeWithMetadataName to efficiently find methods with [MessageHandler] + // Step 1: Use ForAttributeWithMetadataName to efficiently find methods with [MessageHandler] attribute. For each method found, build a MethodAnalysisResult. var methodAnalysisResults = context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: MessageHandlerAttributeFullName, @@ -32,7 +32,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) transform: static (ctx, ct) => SemanticAnalyzer.AnalyzeMethod(ctx, ct)) .Where(static result => !string.IsNullOrEmpty(result.ClassKey)); - // Step 2: Collect all results and group by class + // Step 2: Collect all MethodAnalysisResults, group by class, and then combine into a single AnalysisResult per class. var groupedByClass = methodAnalysisResults .Collect() .SelectMany(static (results, _) => @@ -43,7 +43,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select(group => SemanticAnalyzer.CombineMethodResults(group)); }); - // Step 3: Generate source for valid executors + // Step 3: Generate source for valid executors using the associted AnalysisResult. context.RegisterSourceOutput( groupedByClass.Where(static r => r.ExecutorInfo is not null), static (ctx, result) => @@ -65,20 +65,23 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + /// + /// Generates a hint (virtual file) name for the generated source file based on the ExecutorInfo. + /// private static string GetHintName(ExecutorInfo info) { var sb = new StringBuilder(); if (!string.IsNullOrEmpty(info.Namespace)) { - sb.Append(info.Namespace); - sb.Append('.'); + sb.Append(info.Namespace) + .Append('.'); } if (info.IsNested) { - sb.Append(info.ContainingTypeChain); - sb.Append('.'); + sb.Append(info.ContainingTypeChain) + .Append('.'); } sb.Append(info.ClassName); @@ -87,8 +90,8 @@ private static string GetHintName(ExecutorInfo info) if (!string.IsNullOrEmpty(info.GenericParameters)) { // Replace < > with underscores for valid file name - sb.Append('_'); - sb.Append(info.GenericParameters!.Length - 2); // Number of type params approximation + sb.Append('_') + .Append(info.GenericParameters!.Length - 2); // Number of type params approximation } sb.Append(".g.cs"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs index 7cab18c70b..98d989979e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/AnalysisResult.cs @@ -11,16 +11,19 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Note: This type is used after the caching layer (in RegisterSourceOutput), /// so it should not cause issue to contain Diagnostic objects directly. /// -internal sealed class AnalysisResult +/// The executor information. +/// Any diagnostics to report. +internal sealed class AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray diagnostics) { - public ExecutorInfo? ExecutorInfo { get; } - public ImmutableArray Diagnostics { get; } - - public AnalysisResult(ExecutorInfo? executorInfo, ImmutableArray diagnostics) - { - this.ExecutorInfo = executorInfo; - this.Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; - } + /// + /// Gets the executor information. + /// + public ExecutorInfo? ExecutorInfo { get; } = executorInfo; + + /// + /// Gets the diagnostics to report. + /// + public ImmutableArray Diagnostics { get; } = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; /// /// Creates a successful result with executor info and no diagnostics. diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs index 7451ae5aa7..48eb6c08ef 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticInfo.cs @@ -6,45 +6,6 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; -/// -/// Represents location information in a form that supports value equality. -/// -internal sealed record LocationInfo( - string FilePath, - TextSpan Span, - LinePositionSpan LineSpan) -{ - /// - /// Creates a LocationInfo from a Roslyn Location. - /// - public static LocationInfo? FromLocation(Location? location) - { - if (location is null || location == Location.None) - { - return null; - } - - var lineSpan = location.GetLineSpan(); - return new LocationInfo( - lineSpan.Path ?? string.Empty, - location.SourceSpan, - lineSpan.Span); - } - - /// - /// Converts back to a Roslyn Location. - /// - public Location ToLocation() - { - if (string.IsNullOrEmpty(this.FilePath)) - { - return Location.None; - } - - return Location.Create(this.FilePath, this.Span, this.LineSpan); - } -} - /// /// 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. @@ -71,9 +32,9 @@ public static DiagnosticInfo Create(string diagnosticId, Location location, para } /// - /// Converts this info back to a Diagnostic. + /// Converts this info back to a Roslyn Diagnostic. /// - public Diagnostic ToDiagnostic(SyntaxTree? syntaxTree) + public Diagnostic ToRoslynDiagnostic(SyntaxTree? syntaxTree) { var descriptor = DiagnosticDescriptors.GetById(this.DiagnosticId); if (descriptor is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs new file mode 100644 index 0000000000..60a95dede4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/DiagnosticLocationInfo.cs @@ -0,0 +1,45 @@ +ο»Ώ// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// Represents location information in a form that supports value equality making it friendly for source gen caching. +/// +internal sealed record DiagnosticLocationInfo( + string FilePath, + TextSpan Span, + LinePositionSpan LineSpan) +{ + /// + /// Creates a LocationInfo from a Roslyn Location. + /// + public static DiagnosticLocationInfo? FromLocation(Location? location) + { + if (location is null || location == Location.None) + { + return null; + } + + var lineSpan = location.GetLineSpan(); + return new DiagnosticLocationInfo( + lineSpan.Path ?? string.Empty, + location.SourceSpan, + lineSpan.Span); + } + + /// + /// Converts back to a Roslyn Location. + /// + public Location ToRoslynLocation() + { + if (string.IsNullOrEmpty(this.FilePath)) + { + return Location.None; + } + + return Location.Create(this.FilePath, this.Span, this.LineSpan); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs index c307e7d733..91720ac809 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs @@ -11,18 +11,13 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// A wrapper around that provides value-based equality. /// This is necessary for incremental generator caching since ImmutableArray uses reference equality. /// -internal readonly struct EquatableArray : IEquatable>, IEnumerable +/// +/// Creates a new from an . +/// +internal readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable where T : IEquatable { - private readonly ImmutableArray _array; - - /// - /// Creates a new from an . - /// - public EquatableArray(ImmutableArray array) - { - this._array = array.IsDefault ? ImmutableArray.Empty : array; - } + private readonly ImmutableArray _array = array.IsDefault ? ImmutableArray.Empty : array; /// /// Gets the underlying array. @@ -80,7 +75,7 @@ public override int GetHashCode() var hashCode = 17; foreach (var item in this._array) { - hashCode = hashCode * 31 + item?.GetHashCode() ?? 0; + hashCode = hashCode * 31 + (item?.GetHashCode() ?? 0); } return hashCode; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index 4febe9d8c9..4624927d42 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -32,7 +32,7 @@ internal sealed record MethodAnalysisResult( bool HasManualConfigureRoutes, // Class location for diagnostics (value-equatable) - LocationInfo? ClassLocation, + DiagnosticLocationInfo? ClassLocation, // Method-level info (null if method validation failed) HandlerInfo? Handler, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets new file mode 100644 index 0000000000..bd5d7b835f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/SkipIncompatibleBuild.targets @@ -0,0 +1,10 @@ + + + + + + + + + + From fed96fa691caf79887f2ee34e3d83305c55dd2c6 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 16:29:07 -0800 Subject: [PATCH 08/13] Trying to fix packing. --- ...Microsoft.Agents.AI.Workflows.Generators.csproj | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index e3e6416087..0165d50d9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -1,4 +1,4 @@ - +ο»Ώ @@ -41,12 +41,16 @@ - - + See: https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/ --> + + + + + From 8b0267e5f8ac096f121a6541e1558bdf7f20691a Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 8 Jan 2026 16:59:26 -0800 Subject: [PATCH 09/13] Still trying to fix pipeline pack. --- .../Directory.Build.targets | 4 ++-- .../Microsoft.Agents.AI.Workflows.Generators.csproj | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets index 7fc2764c90..9808af77f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Directory.Build.targets @@ -5,7 +5,7 @@ - + <_SkipIncompatibleBuild>true @@ -13,6 +13,6 @@ true - + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj index 0165d50d9e..ea512b1895 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj @@ -16,10 +16,13 @@ true true + + false + - true + $(NoWarn);nullable From 00e932d2c87c5fd8a2a2e824d1e71113c61a5bca Mon Sep 17 00:00:00 2001 From: alliscode Date: Fri, 9 Jan 2026 14:22:14 -0800 Subject: [PATCH 10/13] Remove obsolescence markers, sample updates, and docs from generator branch. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit separates the generator core functionality from the deprecation of ReflectingExecutor. The removed changes will be re-added in a dependent branch (wf-obsolete-reflector). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Concurrent/Concurrent/Program.cs | 7 +- .../Workflows/Directory.Build.props | 13 - .../Reflection/IMessageHandler.cs | 13 - .../Reflection/MessageHandlerInfo.cs | 2 - .../Reflection/ReflectingExecutor.cs | 7 - .../Reflection/RouteBuilderExtensions.cs | 2 - dotnet/wf-code-gen-impact.md | 257 ---------- dotnet/wf-source-gen-bp.md | 439 ------------------ dotnet/wf-source-gen-changes.md | 258 ---------- wf-source-gen-plan.md | 293 ------------ 10 files changed, 3 insertions(+), 1288 deletions(-) delete mode 100644 dotnet/samples/GettingStarted/Workflows/Directory.Build.props delete mode 100644 dotnet/wf-code-gen-impact.md delete mode 100644 dotnet/wf-source-gen-bp.md delete mode 100644 dotnet/wf-source-gen-changes.md delete mode 100644 wf-source-gen-plan.md diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index e5373554c3..c839149d6c 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -72,8 +72,8 @@ private static async Task Main() /// /// Executor that starts the concurrent processing by sending messages to the agents. /// -internal sealed partial class ConcurrentStartExecutor() : - Executor("ConcurrentStartExecutor") +internal sealed class ConcurrentStartExecutor() : + Executor("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. @@ -83,8 +83,7 @@ internal sealed partial class ConcurrentStartExecutor() : /// The to monitor for cancellation requests. /// The default is . /// A task representing the asynchronous operation - [MessageHandler] - public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. diff --git a/dotnet/samples/GettingStarted/Workflows/Directory.Build.props b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props deleted file mode 100644 index 8ad5839332..0000000000 --- a/dotnet/samples/GettingStarted/Workflows/Directory.Build.props +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs index fe1a777859..3b18379907 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs @@ -1,6 +1,5 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. -using System; using System.Threading; using System.Threading.Tasks; @@ -10,12 +9,6 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// A message handler interface for handling messages of type . /// /// -/// -/// This interface is obsolete. Use the on methods in a partial class -/// deriving from instead. -/// -[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + - "This interface will be removed in a future version.")] public interface IMessageHandler { /// @@ -35,12 +28,6 @@ public interface IMessageHandler /// /// The type of message to handle. /// The type of result returned after handling the message. -/// -/// This interface is obsolete. Use the on methods in a partial class -/// deriving from instead. -/// -[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + - "This interface will be removed in a future version.")] public interface IMessageHandler { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs index f655c27cd4..f63a43b4a8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs @@ -1,7 +1,5 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. -#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility - using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index f4dcf1291f..d96f9319f4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -1,6 +1,5 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. -using System; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI.Workflows.Reflection; @@ -11,12 +10,6 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// The actual type of the . /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// -/// -/// This type is obsolete. Use the on methods in a partial class -/// deriving from instead. -/// -[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + - "This type will be removed in a future version.")] public class ReflectingExecutor< [DynamicallyAccessedMembers( ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs index d554138f1e..f25f896db9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs @@ -1,7 +1,5 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. -#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.Diagnostics; diff --git a/dotnet/wf-code-gen-impact.md b/dotnet/wf-code-gen-impact.md deleted file mode 100644 index b49c8c0594..0000000000 --- a/dotnet/wf-code-gen-impact.md +++ /dev/null @@ -1,257 +0,0 @@ -# Source Generator for Workflow Executors: Rationale and Impact - -## Overview - -The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users. - -## Why Move from Reflection to Code Generation? - -### The Previous Approach: `ReflectingExecutor` - -Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor` and implemented marker interfaces like `IMessageHandler`: - -```csharp -// Old approach - reflection-based -public class MyExecutor : ReflectingExecutor, - IMessageHandler, - IMessageHandler -{ - public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle query - } - - public ValueTask HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle command and return result - } -} -``` - -This approach had several limitations: - -1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization -2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation -3. **Redundant declarations**: The interface list duplicated information already present in method signatures -4. **Limited metadata**: No clean way to declare yield/send types for protocol validation -5. **Hidden errors**: Invalid handler signatures weren't caught until runtime - -### The New Approach: `[MessageHandler]` Attribute - -The source generator enables a cleaner, attribute-based pattern: - -```csharp -// New approach - source generated -[SendsMessage(typeof(PollToken))] -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle query - } - - [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] - private ValueTask HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle command and return result - } -} -``` - -The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time. - -## What's Better About Code Generation? - -### 1. Compile-Time Validation - -Invalid handler signatures are caught during compilation, not at runtime: - -```csharp -[MessageHandler] -private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter -{ -} -``` - -Diagnostic errors include: -- `WFGEN001`: Handler missing `IWorkflowContext` parameter -- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask`) -- `WFGEN003`: Executor class must be `partial` -- `WFGEN004`: `[MessageHandler]` on non-Executor class -- `WFGEN005`: Insufficient parameters -- `WFGEN006`: `ConfigureRoutes` already manually defined - -### 2. Zero Runtime Reflection - -All handler registration happens at compile time. The generated code is simple, direct method calls: - -```csharp -// Generated code -protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) -{ - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleCommandAsync); -} -``` - -This eliminates: -- Reflection overhead during initialization -- Assembly scanning -- Dynamic delegate creation - -### 3. Native AOT Compatibility - -Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables: -- Faster startup times -- Smaller deployment sizes -- Deployment to environments that don't support JIT compilation - -### 4. Explicit Protocol Metadata - -The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation: - -```csharp -[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages -[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output -public partial class MyExecutor : Executor -{ - [MessageHandler( - Yield = [typeof(StreamChunk)], // This handler yields StreamChunk - Send = [typeof(InternalQuery)])] // This handler sends InternalQuery - private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... } -} -``` - -This metadata enables: -- Static protocol validation -- Better IDE tooling and documentation -- Clearer code intent - -### 5. Handler Accessibility Freedom - -Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations: - -```csharp -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx) - { - // Private handler - implementation detail - } -} -``` - -### 6. Cleaner Inheritance - -The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate: - -```csharp -public partial class DerivedExecutor : BaseExecutor -{ - [MessageHandler] - private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... } -} - -// Generated: -protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) -{ - routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers - return routeBuilder - .AddHandler(this.HandleDerivedAsync); -} -``` - -## New Capabilities Enabled - -### 1. Static Workflow Analysis - -With explicit yield/send metadata, tools can analyze workflow graphs at compile time: -- Validate that all message types have handlers -- Detect unreachable executors -- Generate workflow documentation - -### 2. Trimming-Safe Deployments - -The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios. - -### 3. Better IDE Experience - -Because the generator runs in the IDE, you get: -- Immediate feedback on handler signature errors -- IntelliSense for generated methods -- Go-to-definition on generated code - -### 4. Protocol Documentation Generation - -The explicit type metadata can be used to generate: -- API documentation -- OpenAPI/Swagger specs for workflow endpoints -- Visual workflow diagrams - -## Impact on Framework Users - -### Migration Path - -Existing code using `ReflectingExecutor` continues to work but is marked `[Obsolete]`. To migrate: - -1. Change base class from `ReflectingExecutor` to `Executor` -2. Add `partial` modifier to the class -3. Replace `IMessageHandler` interfaces with `[MessageHandler]` attributes -4. Optionally add `Yield`/`Send` metadata for protocol validation - -**Before:** -```csharp -public class MyExecutor : ReflectingExecutor, IMessageHandler -{ - public ValueTask HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } -} -``` - -**After:** -```csharp -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } -} -``` - -### Breaking Changes - -- Classes using `[MessageHandler]` **must** be `partial` -- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)` -- Return type must be `void`, `ValueTask`, or `ValueTask` - -### Performance Improvements - -Users can expect: -- **Faster executor initialization**: No reflection overhead -- **Reduced memory allocation**: No dynamic delegate creation -- **AOT deployment support**: Full Native AOT compatibility -- **Smaller trimmed deployments**: No reflection metadata preserved - -### NuGet Package - -The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it: -- Runs automatically during build -- Requires no additional configuration -- Works in all IDEs that support Roslyn analyzers - -## Summary - -The move from reflection to source generation represents a significant improvement in the Workflows framework: - -| Aspect | Reflection (Old) | Source Generator (New) | -|--------|------------------|------------------------| -| Handler discovery | Runtime | Compile-time | -| Error detection | Runtime exceptions | Compiler errors | -| AOT support | No | Yes | -| Trimming support | Limited | Full | -| Protocol metadata | Implicit | Explicit | -| Handler visibility | Public only | Any | -| Initialization speed | Slower | Faster | - -The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential. diff --git a/dotnet/wf-source-gen-bp.md b/dotnet/wf-source-gen-bp.md deleted file mode 100644 index c0f3d25892..0000000000 --- a/dotnet/wf-source-gen-bp.md +++ /dev/null @@ -1,439 +0,0 @@ -# Source Generator Best Practices Review - -This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository. - -## Reference Documentation - -- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md) -- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md) - ---- - -## Executive Summary - -| Category | Status | Priority | -|----------|--------|----------| -| Generator Type | PASS | - | -| Attribute-Based Detection | FAIL | HIGH | -| Model Value Equality | FAIL | HIGH | -| Collection Equality | FAIL | HIGH | -| Symbol/SyntaxNode Storage | PASS | - | -| Code Generation Approach | PASS | - | -| Diagnostics | PASS | - | -| Pipeline Efficiency | FAIL | MEDIUM | -| CancellationToken Handling | PARTIAL | LOW | - -**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`. - ---- - -## Detailed Analysis - -### 1. Generator Interface Selection - -**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:19 -public sealed class ExecutorRouteGenerator : IIncrementalGenerator -``` - -The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators. - ---- - -### 2. Attribute-Based Detection with ForAttributeWithMetadataName - -**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery. - -> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// ExecutorRouteGenerator.cs:25-30 -var executorCandidates = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) -``` - -**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup. - -**Recommended Fix**: - -```csharp -var executorCandidates = context.SyntaxProvider - .ForAttributeWithMetadataName( - fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute", - predicate: static (node, _) => node is MethodDeclarationSyntax, - transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct)) - .Collect() - .SelectMany((methods, _) => GroupByContainingClass(methods)); -``` - -**Impact**: Current approach causes IDE lag on every keystroke in large projects. - ---- - -### 3. Model Value Equality (Records vs Classes) - -**Best Practice**: Use `record` types for pipeline models to get automatic value equality. - -> "Use `record`s, rather than `class`es, so that value equality is generated for you." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// HandlerInfo.cs:28 -internal sealed class HandlerInfo { ... } - -// ExecutorInfo.cs:10 -internal sealed class ExecutorInfo { ... } -``` - -**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparisonβ€”when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely. - -**Recommended Fix**: - -```csharp -// HandlerInfo.cs -internal sealed record HandlerInfo( - string MethodName, - string InputTypeName, - string? OutputTypeName, - HandlerSignatureKind SignatureKind, - bool HasCancellationToken, - EquatableArray? YieldTypes, - EquatableArray? SendTypes); - -// ExecutorInfo.cs -internal sealed record ExecutorInfo( - string? Namespace, - string ClassName, - string? GenericParameters, - bool IsNested, - string ContainingTypeChain, - bool BaseHasConfigureRoutes, - EquatableArray Handlers, - EquatableArray ClassSendTypes, - EquatableArray ClassYieldTypes); -``` - -**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed. - ---- - -### 4. Collection Equality - -**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray` uses reference equality. - -> "Arrays, `ImmutableArray`, and `List` use reference equality by default. Wrap collections with custom types implementing value-based equality." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// ExecutorInfo.cs:46 -public ImmutableArray Handlers { get; } - -// HandlerInfo.cs:58-63 -public ImmutableArray? YieldTypes { get; } -public ImmutableArray? SendTypes { get; } -``` - -**Problem**: `ImmutableArray` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching. - -**Recommended Fix**: Create an `EquatableArray` wrapper: - -```csharp -internal readonly struct EquatableArray : IEquatable>, IEnumerable - where T : IEquatable -{ - private readonly ImmutableArray _array; - - public EquatableArray(ImmutableArray array) => _array = array; - - public bool Equals(EquatableArray other) - { - if (_array.Length != other._array.Length) return false; - for (int i = 0; i < _array.Length; i++) - { - if (!_array[i].Equals(other._array[i])) return false; - } - return true; - } - - public override int GetHashCode() - { - var hash = new HashCode(); - foreach (var item in _array) hash.Add(item); - return hash.ToHashCode(); - } - - // ... IEnumerable implementation -} -``` - -**Impact**: Same as model equalityβ€”caching is completely broken for handlers and type arrays. - ---- - -### 5. Symbol and SyntaxNode Storage - -**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models. - -> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you needβ€”typically string representations work wellβ€”into your equatable models." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PASS - -The models correctly store only primitive types and strings: - -```csharp -// HandlerInfo.cs - stores strings, not symbols -public string MethodName { get; } -public string InputTypeName { get; } -public string? OutputTypeName { get; } - -// ExecutorInfo.cs - stores strings, not symbols -public string? Namespace { get; } -public string ClassName { get; } -``` - -The `SemanticAnalyzer` correctly extracts string representations from symbols: - -```csharp -// SemanticAnalyzer.cs:300-301 -var inputType = methodSymbol.Parameters[0].Type; -var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); -``` - ---- - -### 6. Code Generation Approach - -**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction. - -> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PASS - -```csharp -// SourceBuilder.cs:17-19 -public static string Generate(ExecutorInfo info) -{ - var sb = new StringBuilder(); -``` - -The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking. - ---- - -### 7. Diagnostic Reporting - -**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:44-50 -context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) => -{ - foreach (var diagnostic in diagnostics) - { - ctx.ReportDiagnostic(diagnostic); - } -}); -``` - -Diagnostics are well-defined with appropriate severities: - -| ID | Severity | Description | -|----|----------|-------------| -| WFGEN001 | Error | Missing IWorkflowContext parameter | -| WFGEN002 | Error | Invalid return type | -| WFGEN003 | Error | Class must be partial | -| WFGEN004 | Warning | Not an Executor | -| WFGEN005 | Error | Insufficient parameters | -| WFGEN006 | Info | ConfigureRoutes already defined | -| WFGEN007 | Error | Handler cannot be static | - ---- - -### 8. Pipeline Efficiency - -**Best Practice**: Avoid duplicate work in the pipeline. - -**Our Implementation**: FAIL (MEDIUM PRIORITY) - -```csharp -// ExecutorRouteGenerator.cs:25-41 -// Pipeline 1: Get executor candidates -var executorCandidates = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) - ... - -// Pipeline 2: Get diagnostics (duplicates the same work!) -var diagnosticsProvider = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => - { - SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); - return diagnostics; - }) -``` - -**Problem**: The same syntax detection and semantic analysis runs twiceβ€”once for extracting `ExecutorInfo` and once for extracting diagnostics. - -**Recommended Fix**: Return both in a single pipeline: - -```csharp -var analysisResults = context.SyntaxProvider - .ForAttributeWithMetadataName(...) - .Select((ctx, ct) => { - var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); - return (Info: info, Diagnostics: diagnostics); - }); - -// Split for different outputs -context.RegisterSourceOutput( - analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!), - GenerateSource); - -context.RegisterSourceOutput( - analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics), - ReportDiagnostics); -``` - ---- - -### 9. Base Type Chain Scanning - -**Best Practice**: Avoid scanning indirect type relationships when possible. - -> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PARTIAL CONCERN - -```csharp -// SemanticAnalyzer.cs:126-141 -private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) -{ - var current = classSymbol.BaseType; - while (current != null) - { - var fullName = current.OriginalDefinition.ToDisplayString(); - if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...)) - { - return true; - } - current = current.BaseType; - } - return false; -} -``` - -**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base typesβ€”which is still targeted. - ---- - -### 10. CancellationToken Handling - -**Best Practice**: Respect `CancellationToken` in long-running operations. - -**Our Implementation**: PARTIAL (LOW PRIORITY) - -The `CancellationToken` is passed through to semantic model calls: - -```csharp -// SemanticAnalyzer.cs:46 -var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken); -``` - -However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks. - ---- - -### 11. File Naming Convention - -**Best Practice**: Use descriptive generated file names with `.g.cs` suffix. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:62-91 -private static string GetHintName(ExecutorInfo info) -{ - // Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs" - ... - sb.Append(".g.cs"); - return sb.ToString(); -} -``` - ---- - -## Recommended Action Plan - -### High Priority (Performance Critical) - -1. **Switch to `ForAttributeWithMetadataName`** - - Estimated impact: 99x+ performance improvement for attribute detection - - Requires restructuring the pipeline to collect methods then group by class - -2. **Convert models to records** - - Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record` - - Enables automatic value equality for incremental caching - -3. **Implement `EquatableArray`** - - Create wrapper struct with value-based equality - - Replace all `ImmutableArray` usages in models - -### Medium Priority (Efficiency) - -4. **Eliminate duplicate pipeline execution** - - Combine info extraction and diagnostic collection into single pipeline - - Split outputs using `Where` and `Select` - -### Low Priority (Polish) - -5. **Add periodic cancellation checks** - - Add `ThrowIfCancellationRequested()` in handler analysis loop - - Only needed for extremely large classes - ---- - -## Compliance Matrix - -| Best Practice | Cookbook Reference | Status | Fix Required | -|--------------|-------------------|--------|--------------| -| Use IIncrementalGenerator | Main cookbook | PASS | No | -| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) | -| Use records for models | Incremental cookbook | FAIL | Yes (High) | -| Implement collection equality | Incremental cookbook | FAIL | Yes (High) | -| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No | -| Use StringBuilder for codegen | Incremental cookbook | PASS | No | -| Report diagnostics properly | Main cookbook | PASS | No | -| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) | -| Respect CancellationToken | Main cookbook | PARTIAL | Optional | -| Use .g.cs file suffix | Main cookbook | PASS | No | -| Additive-only generation | Main cookbook | PASS | No | -| No language feature emulation | Main cookbook | PASS | No | - ---- - -## Conclusion - -The source generator implementation demonstrates solid understanding of Roslyn generator fundamentalsβ€”correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments. - -The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes." diff --git a/dotnet/wf-source-gen-changes.md b/dotnet/wf-source-gen-changes.md deleted file mode 100644 index cc0aca5157..0000000000 --- a/dotnet/wf-source-gen-changes.md +++ /dev/null @@ -1,258 +0,0 @@ -# Workflow Executor Route Source Generator - Implementation Summary - -This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor` pattern with compile-time code generation using `[MessageHandler]` attributes. - -## Overview - -The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor`. - -## New Files Created - -### Attributes (3 files) - -| File | Purpose | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays | -| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send | -| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield | - -### Source Generator Project (8 files) - -| File | Purpose | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings | -| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` | -| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information | -| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information | -| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection | -| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction | -| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic | -| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions | - -## Files Modified - -### Project Files - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests | -| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 | -| `agent-framework-dotnet.slnx` | Added generator project to solution | - -### Obsolete Annotations - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance | -| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler` and `IMessageHandler` interfaces | - -### Pragma Suppressions for Internal Obsolete Usage - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` | - -### Test File Pragma Suppressions - -| File | Changes | -|------|---------| -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | - -## Attribute Definitions - -### MessageHandlerAttribute - -```csharp -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] -public sealed class MessageHandlerAttribute : Attribute -{ - public Type[]? Yield { get; set; } // Types yielded as workflow outputs - public Type[]? Send { get; set; } // Types sent to other executors -} -``` - -### SendsMessageAttribute - -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class SendsMessageAttribute : Attribute -{ - public Type Type { get; } - public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); -} -``` - -### YieldsMessageAttribute - -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class YieldsMessageAttribute : Attribute -{ - public Type Type { get; } - public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); -} -``` - -## Diagnostic Rules - -| ID | Severity | Description | -|----|----------|-------------| -| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) | -| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext | -| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask | -| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial | -| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) | -| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored | -| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken | - -## Handler Signature Support - -The generator supports the following method signatures: - -| Return Type | Parameters | Generated Call | -|-------------|------------|----------------| -| `void` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | - -## Generated Code Example - -### Input (User Code) - -```csharp -[SendsMessage(typeof(PollToken))] -public partial class MyChatExecutor : Executor -{ - [MessageHandler] - private async ValueTask HandleQueryAsync( - ChatQuery query, IWorkflowContext ctx, CancellationToken ct) - { - return new ChatResponse(...); - } - - [MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })] - private void HandleStream(StreamRequest req, IWorkflowContext ctx) - { - // Handler implementation - } -} -``` - -### Output (Generated Code) - -```csharp -// -#nullable enable - -namespace MyNamespace; - -partial class MyChatExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleStream); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(PollToken)); - types.Add(typeof(InternalMessage)); - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(ChatResponse)); - types.Add(typeof(StreamChunk)); - return types; - } -} -``` - -## Build Issues Resolved - -### 1. NU1008 - Central Package Management -Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items. - -### 2. RS2008 - Analyzer Release Tracking -Roslyn requires analyzer release tracking documentation. Fixed by adding `$(NoWarn);RS2008` to the generator project. - -### 3. CA1068 - CancellationToken Parameter Order -Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last. - -### 4. RCS1146 - Conditional Access -Used null check with `&&` instead of `?.` operator. Fixed by using conditional access. - -### 5. CA1310 - StringComparison -`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`. - -### 6. CS0103 - Missing Using Directive -Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive. - -### 7. CS0618 - Obsolete Warnings as Errors -Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files. - -### 8. NU1109 - Package Version Conflict -`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`. - -### 9. RS1041 - Wrong Target Framework for Analyzer -The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`. - -## Migration Guide - -### Before (Reflection-based) - -```csharp -public class MyExecutor : ReflectingExecutor, IMessageHandler -{ - public MyExecutor() : base("MyExecutor") { } - - public ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) - { - // Handler implementation - } -} -``` - -### After (Source Generator) - -```csharp -public partial class MyExecutor : Executor -{ - public MyExecutor() : base("MyExecutor") { } - - [MessageHandler] - private ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) - { - // Handler implementation - } -} -``` - -Key migration steps: -1. Change base class from `ReflectingExecutor` to `Executor` -2. Add `partial` modifier to the class -3. Remove `IMessageHandler` interface implementations -4. Add `[MessageHandler]` attribute to handler methods -5. Handler methods can now be any accessibility (private, protected, internal, public) - -## Future Work - -- Create comprehensive unit tests for the source generator -- Add integration tests verifying generated routes match reflection-discovered routes -- Consider adding IDE quick-fix for migrating from `ReflectingExecutor` pattern diff --git a/wf-source-gen-plan.md b/wf-source-gen-plan.md deleted file mode 100644 index e936b538b2..0000000000 --- a/wf-source-gen-plan.md +++ /dev/null @@ -1,293 +0,0 @@ -# Roslyn Source Generator for Workflow Executor Routes - -## Overview - -Replace the reflection-based `ReflectingExecutor` pattern with a compile-time source generator that discovers `[MessageHandler]` attributed methods and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` implementations. - -## Design Decisions (Confirmed) - -- **Attribute syntax**: Inline properties on `[MessageHandler(Yield=[...], Send=[...])]` -- **Class-level attributes**: Generate `ConfigureSentTypes()`/`ConfigureYieldTypes()` from `[SendsMessage]`/`[YieldsMessage]` -- **Migration**: Clean break - requires direct `Executor` inheritance (not `ReflectingExecutor`) -- **Handler accessibility**: Any (private, protected, internal, public) - ---- - -## Implementation Steps - -### Phase 1: Create Source Generator Project - -**1.1 Create project structure:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ -β”œβ”€β”€ Microsoft.Agents.AI.Workflows.Generators.csproj -β”œβ”€β”€ ExecutorRouteGenerator.cs # Main incremental generator -β”œβ”€β”€ Models/ -β”‚ β”œβ”€β”€ ExecutorInfo.cs # Data model for executor analysis -β”‚ └── HandlerInfo.cs # Data model for handler methods -β”œβ”€β”€ Analysis/ -β”‚ β”œβ”€β”€ SyntaxDetector.cs # Syntax-based candidate detection -β”‚ └── SemanticAnalyzer.cs # Semantic model analysis -β”œβ”€β”€ Generation/ -β”‚ └── SourceBuilder.cs # Code generation logic -└── Diagnostics/ - └── DiagnosticDescriptors.cs # Analyzer diagnostics -``` - -**1.2 Project file configuration:** -- Target `netstandard2.0` -- Reference `Microsoft.CodeAnalysis.CSharp` 4.8.0+ -- Set `IsRoslynComponent=true`, `EnforceExtendedAnalyzerRules=true` -- Package as analyzer in `analyzers/dotnet/cs` - -### Phase 2: Define Attributes - -**2.1 Create `MessageHandlerAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] -public sealed class MessageHandlerAttribute : Attribute -{ - public Type[]? Yield { get; set; } // Types yielded as workflow outputs - public Type[]? Send { get; set; } // Types sent to other executors -} -``` - -**2.2 Create `SendsMessageAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class SendsMessageAttribute : Attribute -{ - public Type Type { get; } - public SendsMessageAttribute(Type type) => this.Type = type; -} -``` - -**2.3 Create `YieldsMessageAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class YieldsMessageAttribute : Attribute -{ - public Type Type { get; } - public YieldsMessageAttribute(Type type) => this.Type = type; -} -``` - -### Phase 3: Implement Source Generator - -**3.1 Detection criteria (syntax level):** -- Class has `partial` modifier -- Class has at least one method with `[MessageHandler]` attribute - -**3.2 Validation criteria (semantic level):** -- Class derives from `Executor` (directly or transitively) -- Class does NOT already define `ConfigureRoutes` with a body -- Handler method has valid signature: `(TMessage, IWorkflowContext[, CancellationToken])` -- Handler returns `void`, `ValueTask`, or `ValueTask` - -**3.3 Handler signature mapping:** - -| Method Signature | Generated AddHandler Call | -|-----------------|---------------------------| -| `void Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `void Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | -| `TResult Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | - -**3.4 Generated code structure:** -```csharp -// -#nullable enable - -namespace MyNamespace; - -partial class MyExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - // Call base if inheriting from another executor with routes - // routeBuilder = base.ConfigureRoutes(routeBuilder); - - return routeBuilder - .AddHandler(this.Handler1) - .AddHandler(this.Handler2); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(SentType1)); - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(YieldType1)); - return types; - } -} -``` - -**3.5 Inheritance handling:** - -| Scenario | Generated `ConfigureRoutes` | -|----------|----------------------------| -| Directly extends `Executor` | No base call (abstract) | -| Extends executor with `[MessageHandler]` methods | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | -| Extends executor with manual `ConfigureRoutes` | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | - -### Phase 4: Analyzer Diagnostics - -| ID | Severity | Condition | -|----|----------|-----------| -| `WFGEN001` | Error | Handler missing `IWorkflowContext` parameter | -| `WFGEN002` | Error | Handler has invalid return type | -| `WFGEN003` | Error | Executor with `[MessageHandler]` must be `partial` | -| `WFGEN004` | Warning | `[MessageHandler]` on non-Executor class | -| `WFGEN005` | Error | Handler has fewer than 2 parameters | -| `WFGEN006` | Info | `ConfigureRoutes` already defined, handlers ignored | - -### Phase 5: Integration & Migration - -**5.1 Wire generator to main project:** -```xml - - - - -``` - -**5.2 Mark `ReflectingExecutor` obsolete:** -```csharp -[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + - "See migration guide. This type will be removed in v1.0.", error: false)] -public class ReflectingExecutor : Executor ... -``` - -**5.3 Mark `IMessageHandler` interfaces obsolete:** -```csharp -[Obsolete("Use [MessageHandler] attribute instead.")] -public interface IMessageHandler { ... } -``` - -### Phase 6: Testing - -**6.1 Generator unit tests:** -``` -dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ -β”œβ”€β”€ ExecutorRouteGeneratorTests.cs -β”œβ”€β”€ SyntaxDetectorTests.cs -β”œβ”€β”€ SemanticAnalyzerTests.cs -└── TestHelpers/ - └── GeneratorTestHelper.cs -``` - -Test cases: -- Simple single handler -- Multiple handlers on one class -- Handlers with different signatures (void, ValueTask, ValueTask) -- Nested classes -- Generic executors -- Inheritance chains (Executor -> CustomBase -> Concrete) -- Class-level `[SendsMessage]`/`[YieldsMessage]` attributes -- Manual `ConfigureRoutes` present (should skip generation) -- Invalid signatures (should produce diagnostics) - -**6.2 Integration tests:** -- Port existing `ReflectingExecutor` test cases to use `[MessageHandler]` -- Verify generated routes match reflection-discovered routes - ---- - -## Files to Create - -| Path | Purpose | -|------|---------| -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Generator project | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main generator | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Syntax analysis | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic analysis | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code gen | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Diagnostics | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Handler attribute | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level send | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level yield | -| `dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/*.cs` | Generator tests | - -## Files to Modify - -| Path | Changes | -|------|---------| -| `dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Add generator reference | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Add `[Obsolete]` | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Add `[Obsolete]` | -| `dotnet/Microsoft.Agents.sln` | Add new projects | - ---- - -## Example Usage (End State) - -```csharp -[SendsMessage(typeof(PollToken))] -public partial class MyChatExecutor : ChatProtocolExecutor -{ - [MessageHandler] - private async ValueTask HandleQueryAsync( - ChatQuery query, IWorkflowContext ctx, CancellationToken ct) - { - // Return type automatically inferred as output - return new ChatResponse(...); - } - - [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] - private void HandleStream(StreamRequest req, IWorkflowContext ctx) - { - // Explicit Yield/Send for complex handlers - } -} -``` - -Generated: -```csharp -partial class MyChatExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - routeBuilder = base.ConfigureRoutes(routeBuilder); - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleStream); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(PollToken)); - types.Add(typeof(InternalMessage)); // From handler attribute - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(ChatResponse)); // From return type - types.Add(typeof(StreamChunk)); // From handler attribute - return types; - } -} -``` From cc3081dffb4fbe2d556e385448cdc7d3d1d62ca6 Mon Sep 17 00:00:00 2001 From: alliscode Date: Fri, 9 Jan 2026 14:24:25 -0800 Subject: [PATCH 11/13] Mark ReflectingExecutor and IMessageHandler as obsolete. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit deprecates the reflection-based handler discovery approach in favor of the new [MessageHandler] attribute with source generation. Changes: - Add [Obsolete] to ReflectingExecutor, IMessageHandler, IMessageHandler - Add #pragma to suppress warnings in internal reflection code - Update Concurrent sample to use new [MessageHandler] pattern - Add Directory.Build.props for samples to include generator - Add documentation files explaining the migration πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Concurrent/Concurrent/Program.cs | 7 +- .../Workflows/Directory.Build.props | 13 + .../Reflection/IMessageHandler.cs | 13 + .../Reflection/MessageHandlerInfo.cs | 2 + .../Reflection/ReflectingExecutor.cs | 7 + .../Reflection/RouteBuilderExtensions.cs | 2 + dotnet/wf-code-gen-impact.md | 257 ++++++++++ dotnet/wf-source-gen-bp.md | 439 ++++++++++++++++++ dotnet/wf-source-gen-changes.md | 258 ++++++++++ wf-source-gen-plan.md | 293 ++++++++++++ 10 files changed, 1288 insertions(+), 3 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Directory.Build.props create mode 100644 dotnet/wf-code-gen-impact.md create mode 100644 dotnet/wf-source-gen-bp.md create mode 100644 dotnet/wf-source-gen-changes.md create mode 100644 wf-source-gen-plan.md diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index c839149d6c..e5373554c3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -72,8 +72,8 @@ private static async Task Main() /// /// Executor that starts the concurrent processing by sending messages to the agents. /// -internal sealed class ConcurrentStartExecutor() : - Executor("ConcurrentStartExecutor") +internal sealed partial class ConcurrentStartExecutor() : + Executor("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. @@ -83,7 +83,8 @@ internal sealed class ConcurrentStartExecutor() : /// The to monitor for cancellation requests. /// The default is . /// A task representing the asynchronous operation - public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + [MessageHandler] + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. diff --git a/dotnet/samples/GettingStarted/Workflows/Directory.Build.props b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props new file mode 100644 index 0000000000..8ad5839332 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs index 3b18379907..fe1a777859 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading; using System.Threading.Tasks; @@ -9,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// A message handler interface for handling messages of type . /// /// +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// @@ -28,6 +35,12 @@ public interface IMessageHandler /// /// The type of message to handle. /// The type of result returned after handling the message. +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs index f63a43b4a8..f655c27cd4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility + using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index d96f9319f4..f4dcf1291f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI.Workflows.Reflection; @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// The actual type of the . /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// +/// +/// This type is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This type will be removed in a future version.")] public class ReflectingExecutor< [DynamicallyAccessedMembers( ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs index f25f896db9..d554138f1e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#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.Diagnostics; diff --git a/dotnet/wf-code-gen-impact.md b/dotnet/wf-code-gen-impact.md new file mode 100644 index 0000000000..b49c8c0594 --- /dev/null +++ b/dotnet/wf-code-gen-impact.md @@ -0,0 +1,257 @@ +# Source Generator for Workflow Executors: Rationale and Impact + +## Overview + +The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users. + +## Why Move from Reflection to Code Generation? + +### The Previous Approach: `ReflectingExecutor` + +Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor` and implemented marker interfaces like `IMessageHandler`: + +```csharp +// Old approach - reflection-based +public class MyExecutor : ReflectingExecutor, + IMessageHandler, + IMessageHandler +{ + public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + public ValueTask HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +This approach had several limitations: + +1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization +2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation +3. **Redundant declarations**: The interface list duplicated information already present in method signatures +4. **Limited metadata**: No clean way to declare yield/send types for protocol validation +5. **Hidden errors**: Invalid handler signatures weren't caught until runtime + +### The New Approach: `[MessageHandler]` Attribute + +The source generator enables a cleaner, attribute-based pattern: + +```csharp +// New approach - source generated +[SendsMessage(typeof(PollToken))] +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private ValueTask HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time. + +## What's Better About Code Generation? + +### 1. Compile-Time Validation + +Invalid handler signatures are caught during compilation, not at runtime: + +```csharp +[MessageHandler] +private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter +{ +} +``` + +Diagnostic errors include: +- `WFGEN001`: Handler missing `IWorkflowContext` parameter +- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask`) +- `WFGEN003`: Executor class must be `partial` +- `WFGEN004`: `[MessageHandler]` on non-Executor class +- `WFGEN005`: Insufficient parameters +- `WFGEN006`: `ConfigureRoutes` already manually defined + +### 2. Zero Runtime Reflection + +All handler registration happens at compile time. The generated code is simple, direct method calls: + +```csharp +// Generated code +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleCommandAsync); +} +``` + +This eliminates: +- Reflection overhead during initialization +- Assembly scanning +- Dynamic delegate creation + +### 3. Native AOT Compatibility + +Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables: +- Faster startup times +- Smaller deployment sizes +- Deployment to environments that don't support JIT compilation + +### 4. Explicit Protocol Metadata + +The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation: + +```csharp +[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages +[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output +public partial class MyExecutor : Executor +{ + [MessageHandler( + Yield = [typeof(StreamChunk)], // This handler yields StreamChunk + Send = [typeof(InternalQuery)])] // This handler sends InternalQuery + private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... } +} +``` + +This metadata enables: +- Static protocol validation +- Better IDE tooling and documentation +- Clearer code intent + +### 5. Handler Accessibility Freedom + +Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations: + +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx) + { + // Private handler - implementation detail + } +} +``` + +### 6. Cleaner Inheritance + +The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate: + +```csharp +public partial class DerivedExecutor : BaseExecutor +{ + [MessageHandler] + private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... } +} + +// Generated: +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers + return routeBuilder + .AddHandler(this.HandleDerivedAsync); +} +``` + +## New Capabilities Enabled + +### 1. Static Workflow Analysis + +With explicit yield/send metadata, tools can analyze workflow graphs at compile time: +- Validate that all message types have handlers +- Detect unreachable executors +- Generate workflow documentation + +### 2. Trimming-Safe Deployments + +The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios. + +### 3. Better IDE Experience + +Because the generator runs in the IDE, you get: +- Immediate feedback on handler signature errors +- IntelliSense for generated methods +- Go-to-definition on generated code + +### 4. Protocol Documentation Generation + +The explicit type metadata can be used to generate: +- API documentation +- OpenAPI/Swagger specs for workflow endpoints +- Visual workflow diagrams + +## Impact on Framework Users + +### Migration Path + +Existing code using `ReflectingExecutor` continues to work but is marked `[Obsolete]`. To migrate: + +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Replace `IMessageHandler` interfaces with `[MessageHandler]` attributes +4. Optionally add `Yield`/`Send` metadata for protocol validation + +**Before:** +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public ValueTask HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +**After:** +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +### Breaking Changes + +- Classes using `[MessageHandler]` **must** be `partial` +- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)` +- Return type must be `void`, `ValueTask`, or `ValueTask` + +### Performance Improvements + +Users can expect: +- **Faster executor initialization**: No reflection overhead +- **Reduced memory allocation**: No dynamic delegate creation +- **AOT deployment support**: Full Native AOT compatibility +- **Smaller trimmed deployments**: No reflection metadata preserved + +### NuGet Package + +The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it: +- Runs automatically during build +- Requires no additional configuration +- Works in all IDEs that support Roslyn analyzers + +## Summary + +The move from reflection to source generation represents a significant improvement in the Workflows framework: + +| Aspect | Reflection (Old) | Source Generator (New) | +|--------|------------------|------------------------| +| Handler discovery | Runtime | Compile-time | +| Error detection | Runtime exceptions | Compiler errors | +| AOT support | No | Yes | +| Trimming support | Limited | Full | +| Protocol metadata | Implicit | Explicit | +| Handler visibility | Public only | Any | +| Initialization speed | Slower | Faster | + +The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential. diff --git a/dotnet/wf-source-gen-bp.md b/dotnet/wf-source-gen-bp.md new file mode 100644 index 0000000000..c0f3d25892 --- /dev/null +++ b/dotnet/wf-source-gen-bp.md @@ -0,0 +1,439 @@ +# Source Generator Best Practices Review + +This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository. + +## Reference Documentation + +- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md) +- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md) + +--- + +## Executive Summary + +| Category | Status | Priority | +|----------|--------|----------| +| Generator Type | PASS | - | +| Attribute-Based Detection | FAIL | HIGH | +| Model Value Equality | FAIL | HIGH | +| Collection Equality | FAIL | HIGH | +| Symbol/SyntaxNode Storage | PASS | - | +| Code Generation Approach | PASS | - | +| Diagnostics | PASS | - | +| Pipeline Efficiency | FAIL | MEDIUM | +| CancellationToken Handling | PARTIAL | LOW | + +**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`. + +--- + +## Detailed Analysis + +### 1. Generator Interface Selection + +**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:19 +public sealed class ExecutorRouteGenerator : IIncrementalGenerator +``` + +The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators. + +--- + +### 2. Attribute-Based Detection with ForAttributeWithMetadataName + +**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery. + +> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-30 +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) +``` + +**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup. + +**Recommended Fix**: + +```csharp +var executorCandidates = context.SyntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute", + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct)) + .Collect() + .SelectMany((methods, _) => GroupByContainingClass(methods)); +``` + +**Impact**: Current approach causes IDE lag on every keystroke in large projects. + +--- + +### 3. Model Value Equality (Records vs Classes) + +**Best Practice**: Use `record` types for pipeline models to get automatic value equality. + +> "Use `record`s, rather than `class`es, so that value equality is generated for you." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// HandlerInfo.cs:28 +internal sealed class HandlerInfo { ... } + +// ExecutorInfo.cs:10 +internal sealed class ExecutorInfo { ... } +``` + +**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparisonβ€”when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely. + +**Recommended Fix**: + +```csharp +// HandlerInfo.cs +internal sealed record HandlerInfo( + string MethodName, + string InputTypeName, + string? OutputTypeName, + HandlerSignatureKind SignatureKind, + bool HasCancellationToken, + EquatableArray? YieldTypes, + EquatableArray? SendTypes); + +// ExecutorInfo.cs +internal sealed record ExecutorInfo( + string? Namespace, + string ClassName, + string? GenericParameters, + bool IsNested, + string ContainingTypeChain, + bool BaseHasConfigureRoutes, + EquatableArray Handlers, + EquatableArray ClassSendTypes, + EquatableArray ClassYieldTypes); +``` + +**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed. + +--- + +### 4. Collection Equality + +**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray` uses reference equality. + +> "Arrays, `ImmutableArray`, and `List` use reference equality by default. Wrap collections with custom types implementing value-based equality." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorInfo.cs:46 +public ImmutableArray Handlers { get; } + +// HandlerInfo.cs:58-63 +public ImmutableArray? YieldTypes { get; } +public ImmutableArray? SendTypes { get; } +``` + +**Problem**: `ImmutableArray` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching. + +**Recommended Fix**: Create an `EquatableArray` wrapper: + +```csharp +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array; + + public EquatableArray(ImmutableArray array) => _array = array; + + public bool Equals(EquatableArray other) + { + if (_array.Length != other._array.Length) return false; + for (int i = 0; i < _array.Length; i++) + { + if (!_array[i].Equals(other._array[i])) return false; + } + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var item in _array) hash.Add(item); + return hash.ToHashCode(); + } + + // ... IEnumerable implementation +} +``` + +**Impact**: Same as model equalityβ€”caching is completely broken for handlers and type arrays. + +--- + +### 5. Symbol and SyntaxNode Storage + +**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models. + +> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you needβ€”typically string representations work wellβ€”into your equatable models." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +The models correctly store only primitive types and strings: + +```csharp +// HandlerInfo.cs - stores strings, not symbols +public string MethodName { get; } +public string InputTypeName { get; } +public string? OutputTypeName { get; } + +// ExecutorInfo.cs - stores strings, not symbols +public string? Namespace { get; } +public string ClassName { get; } +``` + +The `SemanticAnalyzer` correctly extracts string representations from symbols: + +```csharp +// SemanticAnalyzer.cs:300-301 +var inputType = methodSymbol.Parameters[0].Type; +var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); +``` + +--- + +### 6. Code Generation Approach + +**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction. + +> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +```csharp +// SourceBuilder.cs:17-19 +public static string Generate(ExecutorInfo info) +{ + var sb = new StringBuilder(); +``` + +The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking. + +--- + +### 7. Diagnostic Reporting + +**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:44-50 +context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) => +{ + foreach (var diagnostic in diagnostics) + { + ctx.ReportDiagnostic(diagnostic); + } +}); +``` + +Diagnostics are well-defined with appropriate severities: + +| ID | Severity | Description | +|----|----------|-------------| +| WFGEN001 | Error | Missing IWorkflowContext parameter | +| WFGEN002 | Error | Invalid return type | +| WFGEN003 | Error | Class must be partial | +| WFGEN004 | Warning | Not an Executor | +| WFGEN005 | Error | Insufficient parameters | +| WFGEN006 | Info | ConfigureRoutes already defined | +| WFGEN007 | Error | Handler cannot be static | + +--- + +### 8. Pipeline Efficiency + +**Best Practice**: Avoid duplicate work in the pipeline. + +**Our Implementation**: FAIL (MEDIUM PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-41 +// Pipeline 1: Get executor candidates +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) + ... + +// Pipeline 2: Get diagnostics (duplicates the same work!) +var diagnosticsProvider = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => + { + SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return diagnostics; + }) +``` + +**Problem**: The same syntax detection and semantic analysis runs twiceβ€”once for extracting `ExecutorInfo` and once for extracting diagnostics. + +**Recommended Fix**: Return both in a single pipeline: + +```csharp +var analysisResults = context.SyntaxProvider + .ForAttributeWithMetadataName(...) + .Select((ctx, ct) => { + var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return (Info: info, Diagnostics: diagnostics); + }); + +// Split for different outputs +context.RegisterSourceOutput( + analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!), + GenerateSource); + +context.RegisterSourceOutput( + analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics), + ReportDiagnostics); +``` + +--- + +### 9. Base Type Chain Scanning + +**Best Practice**: Avoid scanning indirect type relationships when possible. + +> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PARTIAL CONCERN + +```csharp +// SemanticAnalyzer.cs:126-141 +private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) +{ + var current = classSymbol.BaseType; + while (current != null) + { + var fullName = current.OriginalDefinition.ToDisplayString(); + if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...)) + { + return true; + } + current = current.BaseType; + } + return false; +} +``` + +**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base typesβ€”which is still targeted. + +--- + +### 10. CancellationToken Handling + +**Best Practice**: Respect `CancellationToken` in long-running operations. + +**Our Implementation**: PARTIAL (LOW PRIORITY) + +The `CancellationToken` is passed through to semantic model calls: + +```csharp +// SemanticAnalyzer.cs:46 +var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken); +``` + +However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks. + +--- + +### 11. File Naming Convention + +**Best Practice**: Use descriptive generated file names with `.g.cs` suffix. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:62-91 +private static string GetHintName(ExecutorInfo info) +{ + // Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs" + ... + sb.Append(".g.cs"); + return sb.ToString(); +} +``` + +--- + +## Recommended Action Plan + +### High Priority (Performance Critical) + +1. **Switch to `ForAttributeWithMetadataName`** + - Estimated impact: 99x+ performance improvement for attribute detection + - Requires restructuring the pipeline to collect methods then group by class + +2. **Convert models to records** + - Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record` + - Enables automatic value equality for incremental caching + +3. **Implement `EquatableArray`** + - Create wrapper struct with value-based equality + - Replace all `ImmutableArray` usages in models + +### Medium Priority (Efficiency) + +4. **Eliminate duplicate pipeline execution** + - Combine info extraction and diagnostic collection into single pipeline + - Split outputs using `Where` and `Select` + +### Low Priority (Polish) + +5. **Add periodic cancellation checks** + - Add `ThrowIfCancellationRequested()` in handler analysis loop + - Only needed for extremely large classes + +--- + +## Compliance Matrix + +| Best Practice | Cookbook Reference | Status | Fix Required | +|--------------|-------------------|--------|--------------| +| Use IIncrementalGenerator | Main cookbook | PASS | No | +| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) | +| Use records for models | Incremental cookbook | FAIL | Yes (High) | +| Implement collection equality | Incremental cookbook | FAIL | Yes (High) | +| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No | +| Use StringBuilder for codegen | Incremental cookbook | PASS | No | +| Report diagnostics properly | Main cookbook | PASS | No | +| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) | +| Respect CancellationToken | Main cookbook | PARTIAL | Optional | +| Use .g.cs file suffix | Main cookbook | PASS | No | +| Additive-only generation | Main cookbook | PASS | No | +| No language feature emulation | Main cookbook | PASS | No | + +--- + +## Conclusion + +The source generator implementation demonstrates solid understanding of Roslyn generator fundamentalsβ€”correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments. + +The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes." diff --git a/dotnet/wf-source-gen-changes.md b/dotnet/wf-source-gen-changes.md new file mode 100644 index 0000000000..cc0aca5157 --- /dev/null +++ b/dotnet/wf-source-gen-changes.md @@ -0,0 +1,258 @@ +# Workflow Executor Route Source Generator - Implementation Summary + +This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor` pattern with compile-time code generation using `[MessageHandler]` attributes. + +## Overview + +The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor`. + +## New Files Created + +### Attributes (3 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays | +| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send | +| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield | + +### Source Generator Project (8 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings | +| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction | +| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic | +| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions | + +## Files Modified + +### Project Files + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests | +| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 | +| `agent-framework-dotnet.slnx` | Added generator project to solution | + +### Obsolete Annotations + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance | +| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler` and `IMessageHandler` interfaces | + +### Pragma Suppressions for Internal Obsolete Usage + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` | + +### Test File Pragma Suppressions + +| File | Changes | +|------|---------| +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | + +## Attribute Definitions + +### MessageHandlerAttribute + +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +### SendsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +### YieldsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +## Diagnostic Rules + +| ID | Severity | Description | +|----|----------|-------------| +| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) | +| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext | +| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask | +| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial | +| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) | +| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored | +| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken | + +## Handler Signature Support + +The generator supports the following method signatures: + +| Return Type | Parameters | Generated Call | +|-------------|------------|----------------| +| `void` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | + +## Generated Code Example + +### Input (User Code) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : Executor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + return new ChatResponse(...); + } + + [MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Handler implementation + } +} +``` + +### Output (Generated Code) + +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); + types.Add(typeof(StreamChunk)); + return types; + } +} +``` + +## Build Issues Resolved + +### 1. NU1008 - Central Package Management +Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items. + +### 2. RS2008 - Analyzer Release Tracking +Roslyn requires analyzer release tracking documentation. Fixed by adding `$(NoWarn);RS2008` to the generator project. + +### 3. CA1068 - CancellationToken Parameter Order +Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last. + +### 4. RCS1146 - Conditional Access +Used null check with `&&` instead of `?.` operator. Fixed by using conditional access. + +### 5. CA1310 - StringComparison +`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`. + +### 6. CS0103 - Missing Using Directive +Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive. + +### 7. CS0618 - Obsolete Warnings as Errors +Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files. + +### 8. NU1109 - Package Version Conflict +`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`. + +### 9. RS1041 - Wrong Target Framework for Analyzer +The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`. + +## Migration Guide + +### Before (Reflection-based) + +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public MyExecutor() : base("MyExecutor") { } + + public ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +### After (Source Generator) + +```csharp +public partial class MyExecutor : Executor +{ + public MyExecutor() : base("MyExecutor") { } + + [MessageHandler] + private ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +Key migration steps: +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Remove `IMessageHandler` interface implementations +4. Add `[MessageHandler]` attribute to handler methods +5. Handler methods can now be any accessibility (private, protected, internal, public) + +## Future Work + +- Create comprehensive unit tests for the source generator +- Add integration tests verifying generated routes match reflection-discovered routes +- Consider adding IDE quick-fix for migrating from `ReflectingExecutor` pattern diff --git a/wf-source-gen-plan.md b/wf-source-gen-plan.md new file mode 100644 index 0000000000..e936b538b2 --- /dev/null +++ b/wf-source-gen-plan.md @@ -0,0 +1,293 @@ +# Roslyn Source Generator for Workflow Executor Routes + +## Overview + +Replace the reflection-based `ReflectingExecutor` pattern with a compile-time source generator that discovers `[MessageHandler]` attributed methods and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` implementations. + +## Design Decisions (Confirmed) + +- **Attribute syntax**: Inline properties on `[MessageHandler(Yield=[...], Send=[...])]` +- **Class-level attributes**: Generate `ConfigureSentTypes()`/`ConfigureYieldTypes()` from `[SendsMessage]`/`[YieldsMessage]` +- **Migration**: Clean break - requires direct `Executor` inheritance (not `ReflectingExecutor`) +- **Handler accessibility**: Any (private, protected, internal, public) + +--- + +## Implementation Steps + +### Phase 1: Create Source Generator Project + +**1.1 Create project structure:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ +β”œβ”€β”€ Microsoft.Agents.AI.Workflows.Generators.csproj +β”œβ”€β”€ ExecutorRouteGenerator.cs # Main incremental generator +β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ ExecutorInfo.cs # Data model for executor analysis +β”‚ └── HandlerInfo.cs # Data model for handler methods +β”œβ”€β”€ Analysis/ +β”‚ β”œβ”€β”€ SyntaxDetector.cs # Syntax-based candidate detection +β”‚ └── SemanticAnalyzer.cs # Semantic model analysis +β”œβ”€β”€ Generation/ +β”‚ └── SourceBuilder.cs # Code generation logic +└── Diagnostics/ + └── DiagnosticDescriptors.cs # Analyzer diagnostics +``` + +**1.2 Project file configuration:** +- Target `netstandard2.0` +- Reference `Microsoft.CodeAnalysis.CSharp` 4.8.0+ +- Set `IsRoslynComponent=true`, `EnforceExtendedAnalyzerRules=true` +- Package as analyzer in `analyzers/dotnet/cs` + +### Phase 2: Define Attributes + +**2.1 Create `MessageHandlerAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +**2.2 Create `SendsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = type; +} +``` + +**2.3 Create `YieldsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = type; +} +``` + +### Phase 3: Implement Source Generator + +**3.1 Detection criteria (syntax level):** +- Class has `partial` modifier +- Class has at least one method with `[MessageHandler]` attribute + +**3.2 Validation criteria (semantic level):** +- Class derives from `Executor` (directly or transitively) +- Class does NOT already define `ConfigureRoutes` with a body +- Handler method has valid signature: `(TMessage, IWorkflowContext[, CancellationToken])` +- Handler returns `void`, `ValueTask`, or `ValueTask` + +**3.3 Handler signature mapping:** + +| Method Signature | Generated AddHandler Call | +|-----------------|---------------------------| +| `void Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `void Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `TResult Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | + +**3.4 Generated code structure:** +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + // Call base if inheriting from another executor with routes + // routeBuilder = base.ConfigureRoutes(routeBuilder); + + return routeBuilder + .AddHandler(this.Handler1) + .AddHandler(this.Handler2); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(SentType1)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(YieldType1)); + return types; + } +} +``` + +**3.5 Inheritance handling:** + +| Scenario | Generated `ConfigureRoutes` | +|----------|----------------------------| +| Directly extends `Executor` | No base call (abstract) | +| Extends executor with `[MessageHandler]` methods | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | +| Extends executor with manual `ConfigureRoutes` | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | + +### Phase 4: Analyzer Diagnostics + +| ID | Severity | Condition | +|----|----------|-----------| +| `WFGEN001` | Error | Handler missing `IWorkflowContext` parameter | +| `WFGEN002` | Error | Handler has invalid return type | +| `WFGEN003` | Error | Executor with `[MessageHandler]` must be `partial` | +| `WFGEN004` | Warning | `[MessageHandler]` on non-Executor class | +| `WFGEN005` | Error | Handler has fewer than 2 parameters | +| `WFGEN006` | Info | `ConfigureRoutes` already defined, handlers ignored | + +### Phase 5: Integration & Migration + +**5.1 Wire generator to main project:** +```xml + + + + +``` + +**5.2 Mark `ReflectingExecutor` obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "See migration guide. This type will be removed in v1.0.", error: false)] +public class ReflectingExecutor : Executor ... +``` + +**5.3 Mark `IMessageHandler` interfaces obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute instead.")] +public interface IMessageHandler { ... } +``` + +### Phase 6: Testing + +**6.1 Generator unit tests:** +``` +dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ +β”œβ”€β”€ ExecutorRouteGeneratorTests.cs +β”œβ”€β”€ SyntaxDetectorTests.cs +β”œβ”€β”€ SemanticAnalyzerTests.cs +└── TestHelpers/ + └── GeneratorTestHelper.cs +``` + +Test cases: +- Simple single handler +- Multiple handlers on one class +- Handlers with different signatures (void, ValueTask, ValueTask) +- Nested classes +- Generic executors +- Inheritance chains (Executor -> CustomBase -> Concrete) +- Class-level `[SendsMessage]`/`[YieldsMessage]` attributes +- Manual `ConfigureRoutes` present (should skip generation) +- Invalid signatures (should produce diagnostics) + +**6.2 Integration tests:** +- Port existing `ReflectingExecutor` test cases to use `[MessageHandler]` +- Verify generated routes match reflection-discovered routes + +--- + +## Files to Create + +| Path | Purpose | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Generator project | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main generator | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Syntax analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code gen | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Diagnostics | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Handler attribute | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level send | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level yield | +| `dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/*.cs` | Generator tests | + +## Files to Modify + +| Path | Changes | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Add generator reference | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Add `[Obsolete]` | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Add `[Obsolete]` | +| `dotnet/Microsoft.Agents.sln` | Add new projects | + +--- + +## Example Usage (End State) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : ChatProtocolExecutor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + // Return type automatically inferred as output + return new ChatResponse(...); + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Explicit Yield/Send for complex handlers + } +} +``` + +Generated: +```csharp +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + routeBuilder = base.ConfigureRoutes(routeBuilder); + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); // From handler attribute + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); // From return type + types.Add(typeof(StreamChunk)); // From handler attribute + return types; + } +} +``` From ffcac101a20aceaeb0cb9efaceab3247df29c2b2 Mon Sep 17 00:00:00 2001 From: alliscode Date: Wed, 7 Jan 2026 13:51:13 -0800 Subject: [PATCH 12/13] Obsoleteing Reflector-based workflow code generation in favor of Source Generators and updating some samples to use new pattern. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit deprecates the reflection-based handler discovery approach in favor of the new [MessageHandler] attribute with source generation. Changes: - Add [Obsolete] to ReflectingExecutor, IMessageHandler, IMessageHandler - Add #pragma to suppress warnings in internal reflection code - Update Concurrent sample to use new [MessageHandler] pattern - Add Directory.Build.props for samples to include generator - Add documentation files explaining the migration πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Concurrent/Concurrent/Program.cs | 7 +- .../Workflows/Directory.Build.props | 13 + .../Models/EquatableArray.cs | 121 +++++ .../Attributes/YieldsMessageAttribute.cs | 49 ++ .../Microsoft.Agents.AI.Workflows/Executor.cs | 2 + .../Reflection/IMessageHandler.cs | 13 + .../Reflection/MessageHandlerInfo.cs | 2 + .../Reflection/ReflectingExecutor.cs | 7 + .../Reflection/RouteBuilderExtensions.cs | 2 + .../StatefulExecutor.cs | 2 + .../ReflectionSmokeTest.cs | 2 + .../Sample/01_Simple_Workflow_Sequential.cs | 2 + .../Sample/02_Simple_Workflow_Condition.cs | 2 + .../Sample/03_Simple_Workflow_Loop.cs | 2 + dotnet/wf-code-gen-impact.md | 257 ++++++++++ dotnet/wf-source-gen-bp.md | 439 ++++++++++++++++++ dotnet/wf-source-gen-changes.md | 258 ++++++++++ wf-source-gen-plan.md | 293 ++++++++++++ 18 files changed, 1470 insertions(+), 3 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Workflows/Directory.Build.props create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs create mode 100644 dotnet/wf-code-gen-impact.md create mode 100644 dotnet/wf-source-gen-bp.md create mode 100644 dotnet/wf-source-gen-changes.md create mode 100644 wf-source-gen-plan.md diff --git a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs index c839149d6c..e5373554c3 100644 --- a/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/Concurrent/Concurrent/Program.cs @@ -72,8 +72,8 @@ private static async Task Main() /// /// Executor that starts the concurrent processing by sending messages to the agents. /// -internal sealed class ConcurrentStartExecutor() : - Executor("ConcurrentStartExecutor") +internal sealed partial class ConcurrentStartExecutor() : + Executor("ConcurrentStartExecutor") { /// /// Starts the concurrent processing by sending messages to the agents. @@ -83,7 +83,8 @@ internal sealed class ConcurrentStartExecutor() : /// The to monitor for cancellation requests. /// The default is . /// A task representing the asynchronous operation - public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + [MessageHandler] + public async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) { // Broadcast the message to all connected agents. Receiving agents will queue // the message but will not start processing until they receive a turn token. diff --git a/dotnet/samples/GettingStarted/Workflows/Directory.Build.props b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props new file mode 100644 index 0000000000..8ad5839332 --- /dev/null +++ b/dotnet/samples/GettingStarted/Workflows/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs new file mode 100644 index 0000000000..91720ac809 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs @@ -0,0 +1,121 @@ +ο»Ώ// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Workflows.Generators.Models; + +/// +/// A wrapper around that provides value-based equality. +/// This is necessary for incremental generator caching since ImmutableArray uses reference equality. +/// +/// +/// Creates a new from an . +/// +internal readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array = array.IsDefault ? ImmutableArray.Empty : array; + + /// + /// Gets the underlying array. + /// + public ImmutableArray AsImmutableArray() => this._array; + + /// + /// Gets the number of elements in the array. + /// + public int Length => this._array.Length; + + /// + /// Gets the element at the specified index. + /// + public T this[int index] => this._array[index]; + + /// + /// Gets whether the array is empty. + /// + public bool IsEmpty => this._array.IsEmpty; + + /// + public bool Equals(EquatableArray other) + { + if (this._array.Length != other._array.Length) + { + return false; + } + + for (int i = 0; i < this._array.Length; i++) + { + if (!this._array[i].Equals(other._array[i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) + { + return obj is EquatableArray other && this.Equals(other); + } + + /// + public override int GetHashCode() + { + if (this._array.IsEmpty) + { + return 0; + } + + var hashCode = 17; + foreach (var item in this._array) + { + hashCode = hashCode * 31 + (item?.GetHashCode() ?? 0); + } + + return hashCode; + } + + /// + public IEnumerator GetEnumerator() + { + return ((IEnumerable)this._array).GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// + /// Equality operator. + /// + public static bool operator ==(EquatableArray left, EquatableArray right) + { + return left.Equals(right); + } + + /// + /// Inequality operator. + /// + public static bool operator !=(EquatableArray left, EquatableArray right) + { + return !left.Equals(right); + } + + /// + /// Creates an empty . + /// + public static EquatableArray Empty => new(ImmutableArray.Empty); + + /// + /// Implicit conversion from . + /// + public static implicit operator EquatableArray(ImmutableArray array) => new(array); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs new file mode 100644 index 0000000000..82ca9106b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs @@ -0,0 +1,49 @@ +ο»Ώ// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Workflows; + +/// +/// Declares that an executor may yield messages of the specified type as workflow outputs. +/// +/// +/// +/// Apply this attribute to an class to declare the types of messages +/// it may yield via . This information is used +/// for protocol validation and documentation. +/// +/// +/// 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. +/// +/// +/// +/// +/// [YieldsMessage(typeof(FinalResult))] +/// [YieldsMessage(typeof(StreamChunk))] +/// public partial class MyExecutor : Executor +/// { +/// // ... +/// } +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + /// + /// Gets the type of message that the executor may yield. + /// + public Type Type { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The type of message that the executor may yield. + /// is . + public YieldsMessageAttribute(Type type) + { + this.Type = Throw.IfNull(type); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs index 741f49e2ab..8480e8e36c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Executor.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#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.Diagnostics; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs index 3b18379907..fe1a777859 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Threading; using System.Threading.Tasks; @@ -9,6 +10,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// A message handler interface for handling messages of type . /// /// +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// @@ -28,6 +35,12 @@ public interface IMessageHandler /// /// The type of message to handle. /// The type of result returned after handling the message. +/// +/// This interface is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This interface will be removed in a future version.")] public interface IMessageHandler { /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs index f63a43b4a8..f655c27cd4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility + using System; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs index d96f9319f4..f4dcf1291f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs @@ -1,5 +1,6 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +using System; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Agents.AI.Workflows.Reflection; @@ -10,6 +11,12 @@ namespace Microsoft.Agents.AI.Workflows.Reflection; /// The actual type of the . /// This is used to reflectively discover handlers for messages without violating ILTrim requirements. /// +/// +/// This type is obsolete. Use the on methods in a partial class +/// deriving from instead. +/// +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "This type will be removed in a future version.")] public class ReflectingExecutor< [DynamicallyAccessedMembers( ReflectionDemands.RuntimeInterfaceDiscoveryAndInvocation) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs index f25f896db9..d554138f1e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#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.Diagnostics; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 12079289a4..234958a98a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Internal use of obsolete types for backward compatibility + using System; using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs index ccf3f7bc8b..55aefa0133 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs index c6d33e13d7..5af52874f6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Linq; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs index 9ee50ae3fb..d44b0babcd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Linq; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs index 62ba2a8a68..61e063df32 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs @@ -1,5 +1,7 @@ ο»Ώ// Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - Testing legacy reflection-based pattern + using System; using System.IO; using System.Threading; diff --git a/dotnet/wf-code-gen-impact.md b/dotnet/wf-code-gen-impact.md new file mode 100644 index 0000000000..b49c8c0594 --- /dev/null +++ b/dotnet/wf-code-gen-impact.md @@ -0,0 +1,257 @@ +# Source Generator for Workflow Executors: Rationale and Impact + +## Overview + +The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users. + +## Why Move from Reflection to Code Generation? + +### The Previous Approach: `ReflectingExecutor` + +Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor` and implemented marker interfaces like `IMessageHandler`: + +```csharp +// Old approach - reflection-based +public class MyExecutor : ReflectingExecutor, + IMessageHandler, + IMessageHandler +{ + public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + public ValueTask HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +This approach had several limitations: + +1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization +2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation +3. **Redundant declarations**: The interface list duplicated information already present in method signatures +4. **Limited metadata**: No clean way to declare yield/send types for protocol validation +5. **Hidden errors**: Invalid handler signatures weren't caught until runtime + +### The New Approach: `[MessageHandler]` Attribute + +The source generator enables a cleaner, attribute-based pattern: + +```csharp +// New approach - source generated +[SendsMessage(typeof(PollToken))] +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle query + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private ValueTask HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) + { + // Handle command and return result + } +} +``` + +The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time. + +## What's Better About Code Generation? + +### 1. Compile-Time Validation + +Invalid handler signatures are caught during compilation, not at runtime: + +```csharp +[MessageHandler] +private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter +{ +} +``` + +Diagnostic errors include: +- `WFGEN001`: Handler missing `IWorkflowContext` parameter +- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask`) +- `WFGEN003`: Executor class must be `partial` +- `WFGEN004`: `[MessageHandler]` on non-Executor class +- `WFGEN005`: Insufficient parameters +- `WFGEN006`: `ConfigureRoutes` already manually defined + +### 2. Zero Runtime Reflection + +All handler registration happens at compile time. The generated code is simple, direct method calls: + +```csharp +// Generated code +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleCommandAsync); +} +``` + +This eliminates: +- Reflection overhead during initialization +- Assembly scanning +- Dynamic delegate creation + +### 3. Native AOT Compatibility + +Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables: +- Faster startup times +- Smaller deployment sizes +- Deployment to environments that don't support JIT compilation + +### 4. Explicit Protocol Metadata + +The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation: + +```csharp +[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages +[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output +public partial class MyExecutor : Executor +{ + [MessageHandler( + Yield = [typeof(StreamChunk)], // This handler yields StreamChunk + Send = [typeof(InternalQuery)])] // This handler sends InternalQuery + private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... } +} +``` + +This metadata enables: +- Static protocol validation +- Better IDE tooling and documentation +- Clearer code intent + +### 5. Handler Accessibility Freedom + +Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations: + +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx) + { + // Private handler - implementation detail + } +} +``` + +### 6. Cleaner Inheritance + +The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate: + +```csharp +public partial class DerivedExecutor : BaseExecutor +{ + [MessageHandler] + private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... } +} + +// Generated: +protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) +{ + routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers + return routeBuilder + .AddHandler(this.HandleDerivedAsync); +} +``` + +## New Capabilities Enabled + +### 1. Static Workflow Analysis + +With explicit yield/send metadata, tools can analyze workflow graphs at compile time: +- Validate that all message types have handlers +- Detect unreachable executors +- Generate workflow documentation + +### 2. Trimming-Safe Deployments + +The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios. + +### 3. Better IDE Experience + +Because the generator runs in the IDE, you get: +- Immediate feedback on handler signature errors +- IntelliSense for generated methods +- Go-to-definition on generated code + +### 4. Protocol Documentation Generation + +The explicit type metadata can be used to generate: +- API documentation +- OpenAPI/Swagger specs for workflow endpoints +- Visual workflow diagrams + +## Impact on Framework Users + +### Migration Path + +Existing code using `ReflectingExecutor` continues to work but is marked `[Obsolete]`. To migrate: + +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Replace `IMessageHandler` interfaces with `[MessageHandler]` attributes +4. Optionally add `Yield`/`Send` metadata for protocol validation + +**Before:** +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public ValueTask HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +**After:** +```csharp +public partial class MyExecutor : Executor +{ + [MessageHandler] + private ValueTask HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } +} +``` + +### Breaking Changes + +- Classes using `[MessageHandler]` **must** be `partial` +- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)` +- Return type must be `void`, `ValueTask`, or `ValueTask` + +### Performance Improvements + +Users can expect: +- **Faster executor initialization**: No reflection overhead +- **Reduced memory allocation**: No dynamic delegate creation +- **AOT deployment support**: Full Native AOT compatibility +- **Smaller trimmed deployments**: No reflection metadata preserved + +### NuGet Package + +The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it: +- Runs automatically during build +- Requires no additional configuration +- Works in all IDEs that support Roslyn analyzers + +## Summary + +The move from reflection to source generation represents a significant improvement in the Workflows framework: + +| Aspect | Reflection (Old) | Source Generator (New) | +|--------|------------------|------------------------| +| Handler discovery | Runtime | Compile-time | +| Error detection | Runtime exceptions | Compiler errors | +| AOT support | No | Yes | +| Trimming support | Limited | Full | +| Protocol metadata | Implicit | Explicit | +| Handler visibility | Public only | Any | +| Initialization speed | Slower | Faster | + +The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential. diff --git a/dotnet/wf-source-gen-bp.md b/dotnet/wf-source-gen-bp.md new file mode 100644 index 0000000000..c0f3d25892 --- /dev/null +++ b/dotnet/wf-source-gen-bp.md @@ -0,0 +1,439 @@ +# Source Generator Best Practices Review + +This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository. + +## Reference Documentation + +- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md) +- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md) + +--- + +## Executive Summary + +| Category | Status | Priority | +|----------|--------|----------| +| Generator Type | PASS | - | +| Attribute-Based Detection | FAIL | HIGH | +| Model Value Equality | FAIL | HIGH | +| Collection Equality | FAIL | HIGH | +| Symbol/SyntaxNode Storage | PASS | - | +| Code Generation Approach | PASS | - | +| Diagnostics | PASS | - | +| Pipeline Efficiency | FAIL | MEDIUM | +| CancellationToken Handling | PARTIAL | LOW | + +**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`. + +--- + +## Detailed Analysis + +### 1. Generator Interface Selection + +**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:19 +public sealed class ExecutorRouteGenerator : IIncrementalGenerator +``` + +The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators. + +--- + +### 2. Attribute-Based Detection with ForAttributeWithMetadataName + +**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery. + +> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-30 +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) +``` + +**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup. + +**Recommended Fix**: + +```csharp +var executorCandidates = context.SyntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute", + predicate: static (node, _) => node is MethodDeclarationSyntax, + transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct)) + .Collect() + .SelectMany((methods, _) => GroupByContainingClass(methods)); +``` + +**Impact**: Current approach causes IDE lag on every keystroke in large projects. + +--- + +### 3. Model Value Equality (Records vs Classes) + +**Best Practice**: Use `record` types for pipeline models to get automatic value equality. + +> "Use `record`s, rather than `class`es, so that value equality is generated for you." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// HandlerInfo.cs:28 +internal sealed class HandlerInfo { ... } + +// ExecutorInfo.cs:10 +internal sealed class ExecutorInfo { ... } +``` + +**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparisonβ€”when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely. + +**Recommended Fix**: + +```csharp +// HandlerInfo.cs +internal sealed record HandlerInfo( + string MethodName, + string InputTypeName, + string? OutputTypeName, + HandlerSignatureKind SignatureKind, + bool HasCancellationToken, + EquatableArray? YieldTypes, + EquatableArray? SendTypes); + +// ExecutorInfo.cs +internal sealed record ExecutorInfo( + string? Namespace, + string ClassName, + string? GenericParameters, + bool IsNested, + string ContainingTypeChain, + bool BaseHasConfigureRoutes, + EquatableArray Handlers, + EquatableArray ClassSendTypes, + EquatableArray ClassYieldTypes); +``` + +**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed. + +--- + +### 4. Collection Equality + +**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray` uses reference equality. + +> "Arrays, `ImmutableArray`, and `List` use reference equality by default. Wrap collections with custom types implementing value-based equality." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: FAIL (HIGH PRIORITY) + +```csharp +// ExecutorInfo.cs:46 +public ImmutableArray Handlers { get; } + +// HandlerInfo.cs:58-63 +public ImmutableArray? YieldTypes { get; } +public ImmutableArray? SendTypes { get; } +``` + +**Problem**: `ImmutableArray` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching. + +**Recommended Fix**: Create an `EquatableArray` wrapper: + +```csharp +internal readonly struct EquatableArray : IEquatable>, IEnumerable + where T : IEquatable +{ + private readonly ImmutableArray _array; + + public EquatableArray(ImmutableArray array) => _array = array; + + public bool Equals(EquatableArray other) + { + if (_array.Length != other._array.Length) return false; + for (int i = 0; i < _array.Length; i++) + { + if (!_array[i].Equals(other._array[i])) return false; + } + return true; + } + + public override int GetHashCode() + { + var hash = new HashCode(); + foreach (var item in _array) hash.Add(item); + return hash.ToHashCode(); + } + + // ... IEnumerable implementation +} +``` + +**Impact**: Same as model equalityβ€”caching is completely broken for handlers and type arrays. + +--- + +### 5. Symbol and SyntaxNode Storage + +**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models. + +> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you needβ€”typically string representations work wellβ€”into your equatable models." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +The models correctly store only primitive types and strings: + +```csharp +// HandlerInfo.cs - stores strings, not symbols +public string MethodName { get; } +public string InputTypeName { get; } +public string? OutputTypeName { get; } + +// ExecutorInfo.cs - stores strings, not symbols +public string? Namespace { get; } +public string ClassName { get; } +``` + +The `SemanticAnalyzer` correctly extracts string representations from symbols: + +```csharp +// SemanticAnalyzer.cs:300-301 +var inputType = methodSymbol.Parameters[0].Type; +var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); +``` + +--- + +### 6. Code Generation Approach + +**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction. + +> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PASS + +```csharp +// SourceBuilder.cs:17-19 +public static string Generate(ExecutorInfo info) +{ + var sb = new StringBuilder(); +``` + +The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking. + +--- + +### 7. Diagnostic Reporting + +**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:44-50 +context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) => +{ + foreach (var diagnostic in diagnostics) + { + ctx.ReportDiagnostic(diagnostic); + } +}); +``` + +Diagnostics are well-defined with appropriate severities: + +| ID | Severity | Description | +|----|----------|-------------| +| WFGEN001 | Error | Missing IWorkflowContext parameter | +| WFGEN002 | Error | Invalid return type | +| WFGEN003 | Error | Class must be partial | +| WFGEN004 | Warning | Not an Executor | +| WFGEN005 | Error | Insufficient parameters | +| WFGEN006 | Info | ConfigureRoutes already defined | +| WFGEN007 | Error | Handler cannot be static | + +--- + +### 8. Pipeline Efficiency + +**Best Practice**: Avoid duplicate work in the pipeline. + +**Our Implementation**: FAIL (MEDIUM PRIORITY) + +```csharp +// ExecutorRouteGenerator.cs:25-41 +// Pipeline 1: Get executor candidates +var executorCandidates = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) + ... + +// Pipeline 2: Get diagnostics (duplicates the same work!) +var diagnosticsProvider = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), + transform: static (ctx, ct) => + { + SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return diagnostics; + }) +``` + +**Problem**: The same syntax detection and semantic analysis runs twiceβ€”once for extracting `ExecutorInfo` and once for extracting diagnostics. + +**Recommended Fix**: Return both in a single pipeline: + +```csharp +var analysisResults = context.SyntaxProvider + .ForAttributeWithMetadataName(...) + .Select((ctx, ct) => { + var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); + return (Info: info, Diagnostics: diagnostics); + }); + +// Split for different outputs +context.RegisterSourceOutput( + analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!), + GenerateSource); + +context.RegisterSourceOutput( + analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics), + ReportDiagnostics); +``` + +--- + +### 9. Base Type Chain Scanning + +**Best Practice**: Avoid scanning indirect type relationships when possible. + +> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke." +> β€” Roslyn Incremental Generators Cookbook + +**Our Implementation**: PARTIAL CONCERN + +```csharp +// SemanticAnalyzer.cs:126-141 +private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) +{ + var current = classSymbol.BaseType; + while (current != null) + { + var fullName = current.OriginalDefinition.ToDisplayString(); + if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...)) + { + return true; + } + current = current.BaseType; + } + return false; +} +``` + +**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base typesβ€”which is still targeted. + +--- + +### 10. CancellationToken Handling + +**Best Practice**: Respect `CancellationToken` in long-running operations. + +**Our Implementation**: PARTIAL (LOW PRIORITY) + +The `CancellationToken` is passed through to semantic model calls: + +```csharp +// SemanticAnalyzer.cs:46 +var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken); +``` + +However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks. + +--- + +### 11. File Naming Convention + +**Best Practice**: Use descriptive generated file names with `.g.cs` suffix. + +**Our Implementation**: PASS + +```csharp +// ExecutorRouteGenerator.cs:62-91 +private static string GetHintName(ExecutorInfo info) +{ + // Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs" + ... + sb.Append(".g.cs"); + return sb.ToString(); +} +``` + +--- + +## Recommended Action Plan + +### High Priority (Performance Critical) + +1. **Switch to `ForAttributeWithMetadataName`** + - Estimated impact: 99x+ performance improvement for attribute detection + - Requires restructuring the pipeline to collect methods then group by class + +2. **Convert models to records** + - Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record` + - Enables automatic value equality for incremental caching + +3. **Implement `EquatableArray`** + - Create wrapper struct with value-based equality + - Replace all `ImmutableArray` usages in models + +### Medium Priority (Efficiency) + +4. **Eliminate duplicate pipeline execution** + - Combine info extraction and diagnostic collection into single pipeline + - Split outputs using `Where` and `Select` + +### Low Priority (Polish) + +5. **Add periodic cancellation checks** + - Add `ThrowIfCancellationRequested()` in handler analysis loop + - Only needed for extremely large classes + +--- + +## Compliance Matrix + +| Best Practice | Cookbook Reference | Status | Fix Required | +|--------------|-------------------|--------|--------------| +| Use IIncrementalGenerator | Main cookbook | PASS | No | +| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) | +| Use records for models | Incremental cookbook | FAIL | Yes (High) | +| Implement collection equality | Incremental cookbook | FAIL | Yes (High) | +| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No | +| Use StringBuilder for codegen | Incremental cookbook | PASS | No | +| Report diagnostics properly | Main cookbook | PASS | No | +| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) | +| Respect CancellationToken | Main cookbook | PARTIAL | Optional | +| Use .g.cs file suffix | Main cookbook | PASS | No | +| Additive-only generation | Main cookbook | PASS | No | +| No language feature emulation | Main cookbook | PASS | No | + +--- + +## Conclusion + +The source generator implementation demonstrates solid understanding of Roslyn generator fundamentalsβ€”correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments. + +The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes." diff --git a/dotnet/wf-source-gen-changes.md b/dotnet/wf-source-gen-changes.md new file mode 100644 index 0000000000..cc0aca5157 --- /dev/null +++ b/dotnet/wf-source-gen-changes.md @@ -0,0 +1,258 @@ +# Workflow Executor Route Source Generator - Implementation Summary + +This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor` pattern with compile-time code generation using `[MessageHandler]` attributes. + +## Overview + +The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor`. + +## New Files Created + +### Attributes (3 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays | +| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send | +| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield | + +### Source Generator Project (8 files) + +| File | Purpose | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings | +| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection | +| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction | +| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic | +| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions | + +## Files Modified + +### Project Files + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests | +| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 | +| `agent-framework-dotnet.slnx` | Added generator project to solution | + +### Obsolete Annotations + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance | +| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler` and `IMessageHandler` interfaces | + +### Pragma Suppressions for Internal Obsolete Usage + +| File | Changes | +|------|---------| +| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` | +| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` | + +### Test File Pragma Suppressions + +| File | Changes | +|------|---------| +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | +| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | + +## Attribute Definitions + +### MessageHandlerAttribute + +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +### SendsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +### YieldsMessageAttribute + +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); +} +``` + +## Diagnostic Rules + +| ID | Severity | Description | +|----|----------|-------------| +| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) | +| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext | +| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask | +| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial | +| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) | +| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored | +| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken | + +## Handler Signature Support + +The generator supports the following method signatures: + +| Return Type | Parameters | Generated Call | +|-------------|------------|----------------| +| `void` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | +| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | + +## Generated Code Example + +### Input (User Code) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : Executor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + return new ChatResponse(...); + } + + [MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Handler implementation + } +} +``` + +### Output (Generated Code) + +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); + types.Add(typeof(StreamChunk)); + return types; + } +} +``` + +## Build Issues Resolved + +### 1. NU1008 - Central Package Management +Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items. + +### 2. RS2008 - Analyzer Release Tracking +Roslyn requires analyzer release tracking documentation. Fixed by adding `$(NoWarn);RS2008` to the generator project. + +### 3. CA1068 - CancellationToken Parameter Order +Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last. + +### 4. RCS1146 - Conditional Access +Used null check with `&&` instead of `?.` operator. Fixed by using conditional access. + +### 5. CA1310 - StringComparison +`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`. + +### 6. CS0103 - Missing Using Directive +Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive. + +### 7. CS0618 - Obsolete Warnings as Errors +Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files. + +### 8. NU1109 - Package Version Conflict +`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`. + +### 9. RS1041 - Wrong Target Framework for Analyzer +The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`. + +## Migration Guide + +### Before (Reflection-based) + +```csharp +public class MyExecutor : ReflectingExecutor, IMessageHandler +{ + public MyExecutor() : base("MyExecutor") { } + + public ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +### After (Source Generator) + +```csharp +public partial class MyExecutor : Executor +{ + public MyExecutor() : base("MyExecutor") { } + + [MessageHandler] + private ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) + { + // Handler implementation + } +} +``` + +Key migration steps: +1. Change base class from `ReflectingExecutor` to `Executor` +2. Add `partial` modifier to the class +3. Remove `IMessageHandler` interface implementations +4. Add `[MessageHandler]` attribute to handler methods +5. Handler methods can now be any accessibility (private, protected, internal, public) + +## Future Work + +- Create comprehensive unit tests for the source generator +- Add integration tests verifying generated routes match reflection-discovered routes +- Consider adding IDE quick-fix for migrating from `ReflectingExecutor` pattern diff --git a/wf-source-gen-plan.md b/wf-source-gen-plan.md new file mode 100644 index 0000000000..e936b538b2 --- /dev/null +++ b/wf-source-gen-plan.md @@ -0,0 +1,293 @@ +# Roslyn Source Generator for Workflow Executor Routes + +## Overview + +Replace the reflection-based `ReflectingExecutor` pattern with a compile-time source generator that discovers `[MessageHandler]` attributed methods and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` implementations. + +## Design Decisions (Confirmed) + +- **Attribute syntax**: Inline properties on `[MessageHandler(Yield=[...], Send=[...])]` +- **Class-level attributes**: Generate `ConfigureSentTypes()`/`ConfigureYieldTypes()` from `[SendsMessage]`/`[YieldsMessage]` +- **Migration**: Clean break - requires direct `Executor` inheritance (not `ReflectingExecutor`) +- **Handler accessibility**: Any (private, protected, internal, public) + +--- + +## Implementation Steps + +### Phase 1: Create Source Generator Project + +**1.1 Create project structure:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ +β”œβ”€β”€ Microsoft.Agents.AI.Workflows.Generators.csproj +β”œβ”€β”€ ExecutorRouteGenerator.cs # Main incremental generator +β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ ExecutorInfo.cs # Data model for executor analysis +β”‚ └── HandlerInfo.cs # Data model for handler methods +β”œβ”€β”€ Analysis/ +β”‚ β”œβ”€β”€ SyntaxDetector.cs # Syntax-based candidate detection +β”‚ └── SemanticAnalyzer.cs # Semantic model analysis +β”œβ”€β”€ Generation/ +β”‚ └── SourceBuilder.cs # Code generation logic +└── Diagnostics/ + └── DiagnosticDescriptors.cs # Analyzer diagnostics +``` + +**1.2 Project file configuration:** +- Target `netstandard2.0` +- Reference `Microsoft.CodeAnalysis.CSharp` 4.8.0+ +- Set `IsRoslynComponent=true`, `EnforceExtendedAnalyzerRules=true` +- Package as analyzer in `analyzers/dotnet/cs` + +### Phase 2: Define Attributes + +**2.1 Create `MessageHandlerAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +public sealed class MessageHandlerAttribute : Attribute +{ + public Type[]? Yield { get; set; } // Types yielded as workflow outputs + public Type[]? Send { get; set; } // Types sent to other executors +} +``` + +**2.2 Create `SendsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class SendsMessageAttribute : Attribute +{ + public Type Type { get; } + public SendsMessageAttribute(Type type) => this.Type = type; +} +``` + +**2.3 Create `YieldsMessageAttribute`:** +``` +dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +``` +```csharp +[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] +public sealed class YieldsMessageAttribute : Attribute +{ + public Type Type { get; } + public YieldsMessageAttribute(Type type) => this.Type = type; +} +``` + +### Phase 3: Implement Source Generator + +**3.1 Detection criteria (syntax level):** +- Class has `partial` modifier +- Class has at least one method with `[MessageHandler]` attribute + +**3.2 Validation criteria (semantic level):** +- Class derives from `Executor` (directly or transitively) +- Class does NOT already define `ConfigureRoutes` with a body +- Handler method has valid signature: `(TMessage, IWorkflowContext[, CancellationToken])` +- Handler returns `void`, `ValueTask`, or `ValueTask` + +**3.3 Handler signature mapping:** + +| Method Signature | Generated AddHandler Call | +|-----------------|---------------------------| +| `void Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `void Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | +| `TResult Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | +| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | + +**3.4 Generated code structure:** +```csharp +// +#nullable enable + +namespace MyNamespace; + +partial class MyExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + // Call base if inheriting from another executor with routes + // routeBuilder = base.ConfigureRoutes(routeBuilder); + + return routeBuilder + .AddHandler(this.Handler1) + .AddHandler(this.Handler2); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(SentType1)); + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(YieldType1)); + return types; + } +} +``` + +**3.5 Inheritance handling:** + +| Scenario | Generated `ConfigureRoutes` | +|----------|----------------------------| +| Directly extends `Executor` | No base call (abstract) | +| Extends executor with `[MessageHandler]` methods | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | +| Extends executor with manual `ConfigureRoutes` | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | + +### Phase 4: Analyzer Diagnostics + +| ID | Severity | Condition | +|----|----------|-----------| +| `WFGEN001` | Error | Handler missing `IWorkflowContext` parameter | +| `WFGEN002` | Error | Handler has invalid return type | +| `WFGEN003` | Error | Executor with `[MessageHandler]` must be `partial` | +| `WFGEN004` | Warning | `[MessageHandler]` on non-Executor class | +| `WFGEN005` | Error | Handler has fewer than 2 parameters | +| `WFGEN006` | Info | `ConfigureRoutes` already defined, handlers ignored | + +### Phase 5: Integration & Migration + +**5.1 Wire generator to main project:** +```xml + + + + +``` + +**5.2 Mark `ReflectingExecutor` obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + + "See migration guide. This type will be removed in v1.0.", error: false)] +public class ReflectingExecutor : Executor ... +``` + +**5.3 Mark `IMessageHandler` interfaces obsolete:** +```csharp +[Obsolete("Use [MessageHandler] attribute instead.")] +public interface IMessageHandler { ... } +``` + +### Phase 6: Testing + +**6.1 Generator unit tests:** +``` +dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ +β”œβ”€β”€ ExecutorRouteGeneratorTests.cs +β”œβ”€β”€ SyntaxDetectorTests.cs +β”œβ”€β”€ SemanticAnalyzerTests.cs +└── TestHelpers/ + └── GeneratorTestHelper.cs +``` + +Test cases: +- Simple single handler +- Multiple handlers on one class +- Handlers with different signatures (void, ValueTask, ValueTask) +- Nested classes +- Generic executors +- Inheritance chains (Executor -> CustomBase -> Concrete) +- Class-level `[SendsMessage]`/`[YieldsMessage]` attributes +- Manual `ConfigureRoutes` present (should skip generation) +- Invalid signatures (should produce diagnostics) + +**6.2 Integration tests:** +- Port existing `ReflectingExecutor` test cases to use `[MessageHandler]` +- Verify generated routes match reflection-discovered routes + +--- + +## Files to Create + +| Path | Purpose | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Generator project | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main generator | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Syntax analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic analysis | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code gen | +| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Diagnostics | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Handler attribute | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level send | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level yield | +| `dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/*.cs` | Generator tests | + +## Files to Modify + +| Path | Changes | +|------|---------| +| `dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Add generator reference | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Add `[Obsolete]` | +| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Add `[Obsolete]` | +| `dotnet/Microsoft.Agents.sln` | Add new projects | + +--- + +## Example Usage (End State) + +```csharp +[SendsMessage(typeof(PollToken))] +public partial class MyChatExecutor : ChatProtocolExecutor +{ + [MessageHandler] + private async ValueTask HandleQueryAsync( + ChatQuery query, IWorkflowContext ctx, CancellationToken ct) + { + // Return type automatically inferred as output + return new ChatResponse(...); + } + + [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] + private void HandleStream(StreamRequest req, IWorkflowContext ctx) + { + // Explicit Yield/Send for complex handlers + } +} +``` + +Generated: +```csharp +partial class MyChatExecutor +{ + protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) + { + routeBuilder = base.ConfigureRoutes(routeBuilder); + return routeBuilder + .AddHandler(this.HandleQueryAsync) + .AddHandler(this.HandleStream); + } + + protected override ISet ConfigureSentTypes() + { + var types = base.ConfigureSentTypes(); + types.Add(typeof(PollToken)); + types.Add(typeof(InternalMessage)); // From handler attribute + return types; + } + + protected override ISet ConfigureYieldTypes() + { + var types = base.ConfigureYieldTypes(); + types.Add(typeof(ChatResponse)); // From return type + types.Add(typeof(StreamChunk)); // From handler attribute + return types; + } +} +``` From b430f85b754f0b6d7d20c17b5c2d91afa71b7612 Mon Sep 17 00:00:00 2001 From: alliscode Date: Thu, 22 Jan 2026 09:45:14 -0800 Subject: [PATCH 13/13] Cleaning up temporary design and progress files. --- .../Models/EquatableArray.cs | 121 ----- .../Attributes/YieldsMessageAttribute.cs | 49 -- dotnet/wf-code-gen-impact.md | 257 ---------- dotnet/wf-source-gen-bp.md | 439 ------------------ dotnet/wf-source-gen-changes.md | 258 ---------- wf-source-gen-plan.md | 293 ------------ 6 files changed, 1417 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs delete mode 100644 dotnet/wf-code-gen-impact.md delete mode 100644 dotnet/wf-source-gen-bp.md delete mode 100644 dotnet/wf-source-gen-changes.md delete mode 100644 wf-source-gen-plan.md diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs deleted file mode 100644 index 91720ac809..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/EquatableArray.cs +++ /dev/null @@ -1,121 +0,0 @@ -ο»Ώ// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Collections.Immutable; - -namespace Microsoft.Agents.AI.Workflows.Generators.Models; - -/// -/// A wrapper around that provides value-based equality. -/// This is necessary for incremental generator caching since ImmutableArray uses reference equality. -/// -/// -/// Creates a new from an . -/// -internal readonly struct EquatableArray(ImmutableArray array) : IEquatable>, IEnumerable - where T : IEquatable -{ - private readonly ImmutableArray _array = array.IsDefault ? ImmutableArray.Empty : array; - - /// - /// Gets the underlying array. - /// - public ImmutableArray AsImmutableArray() => this._array; - - /// - /// Gets the number of elements in the array. - /// - public int Length => this._array.Length; - - /// - /// Gets the element at the specified index. - /// - public T this[int index] => this._array[index]; - - /// - /// Gets whether the array is empty. - /// - public bool IsEmpty => this._array.IsEmpty; - - /// - public bool Equals(EquatableArray other) - { - if (this._array.Length != other._array.Length) - { - return false; - } - - for (int i = 0; i < this._array.Length; i++) - { - if (!this._array[i].Equals(other._array[i])) - { - return false; - } - } - - return true; - } - - /// - public override bool Equals(object? obj) - { - return obj is EquatableArray other && this.Equals(other); - } - - /// - public override int GetHashCode() - { - if (this._array.IsEmpty) - { - return 0; - } - - var hashCode = 17; - foreach (var item in this._array) - { - hashCode = hashCode * 31 + (item?.GetHashCode() ?? 0); - } - - return hashCode; - } - - /// - public IEnumerator GetEnumerator() - { - return ((IEnumerable)this._array).GetEnumerator(); - } - - /// - IEnumerator IEnumerable.GetEnumerator() - { - return this.GetEnumerator(); - } - - /// - /// Equality operator. - /// - public static bool operator ==(EquatableArray left, EquatableArray right) - { - return left.Equals(right); - } - - /// - /// Inequality operator. - /// - public static bool operator !=(EquatableArray left, EquatableArray right) - { - return !left.Equals(right); - } - - /// - /// Creates an empty . - /// - public static EquatableArray Empty => new(ImmutableArray.Empty); - - /// - /// Implicit conversion from . - /// - public static implicit operator EquatableArray(ImmutableArray array) => new(array); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs deleted file mode 100644 index 82ca9106b7..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +++ /dev/null @@ -1,49 +0,0 @@ -ο»Ώ// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Workflows; - -/// -/// Declares that an executor may yield messages of the specified type as workflow outputs. -/// -/// -/// -/// Apply this attribute to an class to declare the types of messages -/// it may yield via . This information is used -/// for protocol validation and documentation. -/// -/// -/// 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. -/// -/// -/// -/// -/// [YieldsMessage(typeof(FinalResult))] -/// [YieldsMessage(typeof(StreamChunk))] -/// public partial class MyExecutor : Executor -/// { -/// // ... -/// } -/// -/// -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class YieldsMessageAttribute : Attribute -{ - /// - /// Gets the type of message that the executor may yield. - /// - public Type Type { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The type of message that the executor may yield. - /// is . - public YieldsMessageAttribute(Type type) - { - this.Type = Throw.IfNull(type); - } -} diff --git a/dotnet/wf-code-gen-impact.md b/dotnet/wf-code-gen-impact.md deleted file mode 100644 index b49c8c0594..0000000000 --- a/dotnet/wf-code-gen-impact.md +++ /dev/null @@ -1,257 +0,0 @@ -# Source Generator for Workflow Executors: Rationale and Impact - -## Overview - -The Microsoft Agents AI Workflows framework has introduced a Roslyn source generator (`Microsoft.Agents.AI.Workflows.Generators`) that replaces the previous reflection-based approach for discovering and registering message handlers. This document explains why this change was made, what benefits it provides, and how it impacts framework users. - -## Why Move from Reflection to Code Generation? - -### The Previous Approach: `ReflectingExecutor` - -Previously, executors that needed automatic handler discovery inherited from `ReflectingExecutor` and implemented marker interfaces like `IMessageHandler`: - -```csharp -// Old approach - reflection-based -public class MyExecutor : ReflectingExecutor, - IMessageHandler, - IMessageHandler -{ - public ValueTask HandleAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle query - } - - public ValueTask HandleAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle command and return result - } -} -``` - -This approach had several limitations: - -1. **Runtime overhead**: Handler discovery happened at runtime via reflection, adding latency to executor initialization -2. **No AOT compatibility**: Reflection-based discovery doesn't work with Native AOT compilation -3. **Redundant declarations**: The interface list duplicated information already present in method signatures -4. **Limited metadata**: No clean way to declare yield/send types for protocol validation -5. **Hidden errors**: Invalid handler signatures weren't caught until runtime - -### The New Approach: `[MessageHandler]` Attribute - -The source generator enables a cleaner, attribute-based pattern: - -```csharp -// New approach - source generated -[SendsMessage(typeof(PollToken))] -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleQueryAsync(QueryMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle query - } - - [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] - private ValueTask HandleCommandAsync(CommandMessage msg, IWorkflowContext ctx, CancellationToken ct) - { - // Handle command and return result - } -} -``` - -The generator produces a partial class with `ConfigureRoutes()`, `ConfigureSentTypes()`, and `ConfigureYieldTypes()` implementations at compile time. - -## What's Better About Code Generation? - -### 1. Compile-Time Validation - -Invalid handler signatures are caught during compilation, not at runtime: - -```csharp -[MessageHandler] -private void InvalidHandler(string msg) // Error WFGEN005: Missing IWorkflowContext parameter -{ -} -``` - -Diagnostic errors include: -- `WFGEN001`: Handler missing `IWorkflowContext` parameter -- `WFGEN002`: Invalid return type (must be `void`, `ValueTask`, or `ValueTask`) -- `WFGEN003`: Executor class must be `partial` -- `WFGEN004`: `[MessageHandler]` on non-Executor class -- `WFGEN005`: Insufficient parameters -- `WFGEN006`: `ConfigureRoutes` already manually defined - -### 2. Zero Runtime Reflection - -All handler registration happens at compile time. The generated code is simple, direct method calls: - -```csharp -// Generated code -protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) -{ - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleCommandAsync); -} -``` - -This eliminates: -- Reflection overhead during initialization -- Assembly scanning -- Dynamic delegate creation - -### 3. Native AOT Compatibility - -Because there's no runtime reflection, executors work seamlessly with .NET Native AOT compilation. This enables: -- Faster startup times -- Smaller deployment sizes -- Deployment to environments that don't support JIT compilation - -### 4. Explicit Protocol Metadata - -The `Yield` and `Send` properties on `[MessageHandler]` plus class-level `[SendsMessage]` and `[YieldsMessage]` attributes provide explicit protocol documentation: - -```csharp -[SendsMessage(typeof(PollToken))] // This executor sends PollToken messages -[YieldsMessage(typeof(FinalResult))] // This executor yields FinalResult to workflow output -public partial class MyExecutor : Executor -{ - [MessageHandler( - Yield = [typeof(StreamChunk)], // This handler yields StreamChunk - Send = [typeof(InternalQuery)])] // This handler sends InternalQuery - private ValueTask HandleAsync(Request req, IWorkflowContext ctx) { ... } -} -``` - -This metadata enables: -- Static protocol validation -- Better IDE tooling and documentation -- Clearer code intent - -### 5. Handler Accessibility Freedom - -Handlers can be `private`, `protected`, `internal`, or `public`. The old interface-based approach required public methods. Now you can encapsulate handler implementations: - -```csharp -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleInternalAsync(InternalMessage msg, IWorkflowContext ctx) - { - // Private handler - implementation detail - } -} -``` - -### 6. Cleaner Inheritance - -The generator properly handles inheritance chains, calling `base.ConfigureRoutes()` when appropriate: - -```csharp -public partial class DerivedExecutor : BaseExecutor -{ - [MessageHandler] - private ValueTask HandleDerivedAsync(DerivedMessage msg, IWorkflowContext ctx) { ... } -} - -// Generated: -protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) -{ - routeBuilder = base.ConfigureRoutes(routeBuilder); // Preserves base handlers - return routeBuilder - .AddHandler(this.HandleDerivedAsync); -} -``` - -## New Capabilities Enabled - -### 1. Static Workflow Analysis - -With explicit yield/send metadata, tools can analyze workflow graphs at compile time: -- Validate that all message types have handlers -- Detect unreachable executors -- Generate workflow documentation - -### 2. Trimming-Safe Deployments - -The generated code contains no reflection, making it fully compatible with IL trimming. This reduces deployment size significantly for serverless and edge scenarios. - -### 3. Better IDE Experience - -Because the generator runs in the IDE, you get: -- Immediate feedback on handler signature errors -- IntelliSense for generated methods -- Go-to-definition on generated code - -### 4. Protocol Documentation Generation - -The explicit type metadata can be used to generate: -- API documentation -- OpenAPI/Swagger specs for workflow endpoints -- Visual workflow diagrams - -## Impact on Framework Users - -### Migration Path - -Existing code using `ReflectingExecutor` continues to work but is marked `[Obsolete]`. To migrate: - -1. Change base class from `ReflectingExecutor` to `Executor` -2. Add `partial` modifier to the class -3. Replace `IMessageHandler` interfaces with `[MessageHandler]` attributes -4. Optionally add `Yield`/`Send` metadata for protocol validation - -**Before:** -```csharp -public class MyExecutor : ReflectingExecutor, IMessageHandler -{ - public ValueTask HandleAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } -} -``` - -**After:** -```csharp -public partial class MyExecutor : Executor -{ - [MessageHandler] - private ValueTask HandleQueryAsync(Query q, IWorkflowContext ctx, CancellationToken ct) { ... } -} -``` - -### Breaking Changes - -- Classes using `[MessageHandler]` **must** be `partial` -- Handler methods must have at least 2 parameters: `(TMessage, IWorkflowContext)` -- Return type must be `void`, `ValueTask`, or `ValueTask` - -### Performance Improvements - -Users can expect: -- **Faster executor initialization**: No reflection overhead -- **Reduced memory allocation**: No dynamic delegate creation -- **AOT deployment support**: Full Native AOT compatibility -- **Smaller trimmed deployments**: No reflection metadata preserved - -### NuGet Package - -The generator is distributed as a separate NuGet package (`Microsoft.Agents.AI.Workflows.Generators`) that's automatically referenced by the main Workflows package. It's packaged as an analyzer, so it: -- Runs automatically during build -- Requires no additional configuration -- Works in all IDEs that support Roslyn analyzers - -## Summary - -The move from reflection to source generation represents a significant improvement in the Workflows framework: - -| Aspect | Reflection (Old) | Source Generator (New) | -|--------|------------------|------------------------| -| Handler discovery | Runtime | Compile-time | -| Error detection | Runtime exceptions | Compiler errors | -| AOT support | No | Yes | -| Trimming support | Limited | Full | -| Protocol metadata | Implicit | Explicit | -| Handler visibility | Public only | Any | -| Initialization speed | Slower | Faster | - -The source generator approach aligns with modern .NET best practices and positions the framework for future scenarios including edge computing, serverless, and mobile deployments where AOT compilation and minimal footprint are essential. diff --git a/dotnet/wf-source-gen-bp.md b/dotnet/wf-source-gen-bp.md deleted file mode 100644 index c0f3d25892..0000000000 --- a/dotnet/wf-source-gen-bp.md +++ /dev/null @@ -1,439 +0,0 @@ -# Source Generator Best Practices Review - -This document reviews the Workflow Executor Route Source Generator implementation against the official Roslyn Source Generator Cookbook best practices from the dotnet/roslyn repository. - -## Reference Documentation - -- [Source Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md) -- [Incremental Generators Cookbook](https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.cookbook.md) - ---- - -## Executive Summary - -| Category | Status | Priority | -|----------|--------|----------| -| Generator Type | PASS | - | -| Attribute-Based Detection | FAIL | HIGH | -| Model Value Equality | FAIL | HIGH | -| Collection Equality | FAIL | HIGH | -| Symbol/SyntaxNode Storage | PASS | - | -| Code Generation Approach | PASS | - | -| Diagnostics | PASS | - | -| Pipeline Efficiency | FAIL | MEDIUM | -| CancellationToken Handling | PARTIAL | LOW | - -**Overall Assessment**: The generator follows several best practices but has critical performance issues that should be addressed before production use. The most significant issue is not using `ForAttributeWithMetadataName`, which the Roslyn team states is "at least 99x more efficient" than `CreateSyntaxProvider`. - ---- - -## Detailed Analysis - -### 1. Generator Interface Selection - -**Best Practice**: Use `IIncrementalGenerator` instead of the deprecated `ISourceGenerator`. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:19 -public sealed class ExecutorRouteGenerator : IIncrementalGenerator -``` - -The generator correctly implements `IIncrementalGenerator`, the recommended interface for new generators. - ---- - -### 2. Attribute-Based Detection with ForAttributeWithMetadataName - -**Best Practice**: Use `ForAttributeWithMetadataName()` for attribute-based discovery. - -> "This utility method is at least 99x more efficient than `SyntaxProvider.CreateSyntaxProvider`, and in many cases even more efficient." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// ExecutorRouteGenerator.cs:25-30 -var executorCandidates = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) -``` - -**Problem**: We use `CreateSyntaxProvider` with manual attribute detection in `SyntaxDetector`. This requires the generator to examine every syntax node in the compilation, whereas `ForAttributeWithMetadataName` uses the compiler's built-in attribute index for O(1) lookup. - -**Recommended Fix**: - -```csharp -var executorCandidates = context.SyntaxProvider - .ForAttributeWithMetadataName( - fullyQualifiedMetadataName: "Microsoft.Agents.AI.Workflows.MessageHandlerAttribute", - predicate: static (node, _) => node is MethodDeclarationSyntax, - transform: static (ctx, ct) => AnalyzeMethodWithAttribute(ctx, ct)) - .Collect() - .SelectMany((methods, _) => GroupByContainingClass(methods)); -``` - -**Impact**: Current approach causes IDE lag on every keystroke in large projects. - ---- - -### 3. Model Value Equality (Records vs Classes) - -**Best Practice**: Use `record` types for pipeline models to get automatic value equality. - -> "Use `record`s, rather than `class`es, so that value equality is generated for you." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// HandlerInfo.cs:28 -internal sealed class HandlerInfo { ... } - -// ExecutorInfo.cs:10 -internal sealed class ExecutorInfo { ... } -``` - -**Problem**: Both `HandlerInfo` and `ExecutorInfo` are `sealed class` types, which use reference equality by default. The incremental generator caches results based on equality comparisonβ€”when the model equals the previous run's model, regeneration is skipped. With reference equality, every analysis produces a "new" object, defeating caching entirely. - -**Recommended Fix**: - -```csharp -// HandlerInfo.cs -internal sealed record HandlerInfo( - string MethodName, - string InputTypeName, - string? OutputTypeName, - HandlerSignatureKind SignatureKind, - bool HasCancellationToken, - EquatableArray? YieldTypes, - EquatableArray? SendTypes); - -// ExecutorInfo.cs -internal sealed record ExecutorInfo( - string? Namespace, - string ClassName, - string? GenericParameters, - bool IsNested, - string ContainingTypeChain, - bool BaseHasConfigureRoutes, - EquatableArray Handlers, - EquatableArray ClassSendTypes, - EquatableArray ClassYieldTypes); -``` - -**Impact**: Without value equality, the generator regenerates code on every compilation even when nothing changed. - ---- - -### 4. Collection Equality - -**Best Practice**: Use custom equatable wrappers for collections since `ImmutableArray` uses reference equality. - -> "Arrays, `ImmutableArray`, and `List` use reference equality by default. Wrap collections with custom types implementing value-based equality." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: FAIL (HIGH PRIORITY) - -```csharp -// ExecutorInfo.cs:46 -public ImmutableArray Handlers { get; } - -// HandlerInfo.cs:58-63 -public ImmutableArray? YieldTypes { get; } -public ImmutableArray? SendTypes { get; } -``` - -**Problem**: `ImmutableArray` compares by reference, not by contents. Two arrays with identical elements are considered unequal, breaking incremental caching. - -**Recommended Fix**: Create an `EquatableArray` wrapper: - -```csharp -internal readonly struct EquatableArray : IEquatable>, IEnumerable - where T : IEquatable -{ - private readonly ImmutableArray _array; - - public EquatableArray(ImmutableArray array) => _array = array; - - public bool Equals(EquatableArray other) - { - if (_array.Length != other._array.Length) return false; - for (int i = 0; i < _array.Length; i++) - { - if (!_array[i].Equals(other._array[i])) return false; - } - return true; - } - - public override int GetHashCode() - { - var hash = new HashCode(); - foreach (var item in _array) hash.Add(item); - return hash.ToHashCode(); - } - - // ... IEnumerable implementation -} -``` - -**Impact**: Same as model equalityβ€”caching is completely broken for handlers and type arrays. - ---- - -### 5. Symbol and SyntaxNode Storage - -**Best Practice**: Never store `ISymbol` or `SyntaxNode` in pipeline models. - -> "Storing `ISymbol` references blocks garbage collection and roots old compilations unnecessarily. Extract only the information you needβ€”typically string representations work wellβ€”into your equatable models." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PASS - -The models correctly store only primitive types and strings: - -```csharp -// HandlerInfo.cs - stores strings, not symbols -public string MethodName { get; } -public string InputTypeName { get; } -public string? OutputTypeName { get; } - -// ExecutorInfo.cs - stores strings, not symbols -public string? Namespace { get; } -public string ClassName { get; } -``` - -The `SemanticAnalyzer` correctly extracts string representations from symbols: - -```csharp -// SemanticAnalyzer.cs:300-301 -var inputType = methodSymbol.Parameters[0].Type; -var inputTypeName = inputType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); -``` - ---- - -### 6. Code Generation Approach - -**Best Practice**: Use `StringBuilder` for code generation, not `SyntaxNode` construction. - -> "Avoid constructing `SyntaxNode`s for output; they're complex to format correctly and `NormalizeWhitespace()` is expensive. Instead, use a `StringBuilder` wrapper that tracks indentation levels." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PASS - -```csharp -// SourceBuilder.cs:17-19 -public static string Generate(ExecutorInfo info) -{ - var sb = new StringBuilder(); -``` - -The `SourceBuilder` correctly uses `StringBuilder` with manual indentation tracking. - ---- - -### 7. Diagnostic Reporting - -**Best Practice**: Use `ReportDiagnostic` for surfacing issues to users. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:44-50 -context.RegisterSourceOutput(diagnosticsProvider, static (ctx, diagnostics) => -{ - foreach (var diagnostic in diagnostics) - { - ctx.ReportDiagnostic(diagnostic); - } -}); -``` - -Diagnostics are well-defined with appropriate severities: - -| ID | Severity | Description | -|----|----------|-------------| -| WFGEN001 | Error | Missing IWorkflowContext parameter | -| WFGEN002 | Error | Invalid return type | -| WFGEN003 | Error | Class must be partial | -| WFGEN004 | Warning | Not an Executor | -| WFGEN005 | Error | Insufficient parameters | -| WFGEN006 | Info | ConfigureRoutes already defined | -| WFGEN007 | Error | Handler cannot be static | - ---- - -### 8. Pipeline Efficiency - -**Best Practice**: Avoid duplicate work in the pipeline. - -**Our Implementation**: FAIL (MEDIUM PRIORITY) - -```csharp -// ExecutorRouteGenerator.cs:25-41 -// Pipeline 1: Get executor candidates -var executorCandidates = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => SemanticAnalyzer.Analyze(ctx, ct, out _)) - ... - -// Pipeline 2: Get diagnostics (duplicates the same work!) -var diagnosticsProvider = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => SyntaxDetector.IsExecutorCandidate(node), - transform: static (ctx, ct) => - { - SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); - return diagnostics; - }) -``` - -**Problem**: The same syntax detection and semantic analysis runs twiceβ€”once for extracting `ExecutorInfo` and once for extracting diagnostics. - -**Recommended Fix**: Return both in a single pipeline: - -```csharp -var analysisResults = context.SyntaxProvider - .ForAttributeWithMetadataName(...) - .Select((ctx, ct) => { - var info = SemanticAnalyzer.Analyze(ctx, ct, out var diagnostics); - return (Info: info, Diagnostics: diagnostics); - }); - -// Split for different outputs -context.RegisterSourceOutput( - analysisResults.Where(r => r.Info != null).Select((r, _) => r.Info!), - GenerateSource); - -context.RegisterSourceOutput( - analysisResults.Where(r => r.Diagnostics.Length > 0).Select((r, _) => r.Diagnostics), - ReportDiagnostics); -``` - ---- - -### 9. Base Type Chain Scanning - -**Best Practice**: Avoid scanning indirect type relationships when possible. - -> "Never scan for types that indirectly implement interfaces, inherit from base types, or acquire attributes through inheritance hierarchies. This pattern forces the generator to inspect every type's `AllInterfaces` or base-type chain on every keystroke." -> β€” Roslyn Incremental Generators Cookbook - -**Our Implementation**: PARTIAL CONCERN - -```csharp -// SemanticAnalyzer.cs:126-141 -private static bool DerivesFromExecutor(INamedTypeSymbol classSymbol) -{ - var current = classSymbol.BaseType; - while (current != null) - { - var fullName = current.OriginalDefinition.ToDisplayString(); - if (fullName == ExecutorTypeName || fullName.StartsWith(ExecutorTypeName + "<", ...)) - { - return true; - } - current = current.BaseType; - } - return false; -} -``` - -**Analysis**: We do walk the base type chain, but this only happens after attribute filtering (classes must have `[MessageHandler]` methods). Since this is targeted to specific candidates rather than scanning all types, the performance impact is acceptable. However, if we switch to `ForAttributeWithMetadataName`, the attribute is on methods, so we'd need to check the containing class's base typesβ€”which is still targeted. - ---- - -### 10. CancellationToken Handling - -**Best Practice**: Respect `CancellationToken` in long-running operations. - -**Our Implementation**: PARTIAL (LOW PRIORITY) - -The `CancellationToken` is passed through to semantic model calls: - -```csharp -// SemanticAnalyzer.cs:46 -var classSymbol = semanticModel.GetDeclaredSymbol(classDecl, cancellationToken); -``` - -However, there are no explicit `cancellationToken.ThrowIfCancellationRequested()` calls in loops like `AnalyzeHandlers`. For most compilations this is fine, but very large classes with many handlers might benefit from periodic checks. - ---- - -### 11. File Naming Convention - -**Best Practice**: Use descriptive generated file names with `.g.cs` suffix. - -**Our Implementation**: PASS - -```csharp -// ExecutorRouteGenerator.cs:62-91 -private static string GetHintName(ExecutorInfo info) -{ - // Produces: "Namespace.ClassName.g.cs" or "Namespace.Outer.Inner.ClassName.g.cs" - ... - sb.Append(".g.cs"); - return sb.ToString(); -} -``` - ---- - -## Recommended Action Plan - -### High Priority (Performance Critical) - -1. **Switch to `ForAttributeWithMetadataName`** - - Estimated impact: 99x+ performance improvement for attribute detection - - Requires restructuring the pipeline to collect methods then group by class - -2. **Convert models to records** - - Change `HandlerInfo` and `ExecutorInfo` from `sealed class` to `sealed record` - - Enables automatic value equality for incremental caching - -3. **Implement `EquatableArray`** - - Create wrapper struct with value-based equality - - Replace all `ImmutableArray` usages in models - -### Medium Priority (Efficiency) - -4. **Eliminate duplicate pipeline execution** - - Combine info extraction and diagnostic collection into single pipeline - - Split outputs using `Where` and `Select` - -### Low Priority (Polish) - -5. **Add periodic cancellation checks** - - Add `ThrowIfCancellationRequested()` in handler analysis loop - - Only needed for extremely large classes - ---- - -## Compliance Matrix - -| Best Practice | Cookbook Reference | Status | Fix Required | -|--------------|-------------------|--------|--------------| -| Use IIncrementalGenerator | Main cookbook | PASS | No | -| Use ForAttributeWithMetadataName | Incremental cookbook | FAIL | Yes (High) | -| Use records for models | Incremental cookbook | FAIL | Yes (High) | -| Implement collection equality | Incremental cookbook | FAIL | Yes (High) | -| Don't store ISymbol/SyntaxNode | Incremental cookbook | PASS | No | -| Use StringBuilder for codegen | Incremental cookbook | PASS | No | -| Report diagnostics properly | Main cookbook | PASS | No | -| Avoid duplicate pipeline work | Incremental cookbook | FAIL | Yes (Medium) | -| Respect CancellationToken | Main cookbook | PARTIAL | Optional | -| Use .g.cs file suffix | Main cookbook | PASS | No | -| Additive-only generation | Main cookbook | PASS | No | -| No language feature emulation | Main cookbook | PASS | No | - ---- - -## Conclusion - -The source generator implementation demonstrates solid understanding of Roslyn generator fundamentalsβ€”correct interface usage, proper diagnostic reporting, and appropriate code generation patterns. However, critical performance optimizations are missing that could cause significant IDE lag in production environments. - -The three high-priority fixes (ForAttributeWithMetadataName, record models, and EquatableArray) should be implemented before the generator is used in large codebases. These changes will enable proper incremental caching, reducing regeneration from "every keystroke" to "only when relevant code changes." diff --git a/dotnet/wf-source-gen-changes.md b/dotnet/wf-source-gen-changes.md deleted file mode 100644 index cc0aca5157..0000000000 --- a/dotnet/wf-source-gen-changes.md +++ /dev/null @@ -1,258 +0,0 @@ -# Workflow Executor Route Source Generator - Implementation Summary - -This document summarizes all changes made to implement a Roslyn source generator that replaces the reflection-based `ReflectingExecutor` pattern with compile-time code generation using `[MessageHandler]` attributes. - -## Overview - -The source generator automatically discovers methods marked with `[MessageHandler]` and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` method implementations at compile time. This improves AOT compatibility and eliminates the need for the CRTP (Curiously Recurring Template Pattern) used by `ReflectingExecutor`. - -## New Files Created - -### Attributes (3 files) - -| File | Purpose | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Marks methods as message handlers with optional `Yield` and `Send` type arrays | -| `src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level attribute declaring message types an executor may send | -| `src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level attribute declaring output types an executor may yield | - -### Source Generator Project (8 files) - -| File | Purpose | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Project file targeting netstandard2.0 with Roslyn component settings | -| `src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main incremental generator implementing `IIncrementalGenerator` | -| `src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model for handler method information | -| `src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model for executor class information | -| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Fast syntax-level candidate detection | -| `src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic validation and type extraction | -| `src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code generation logic | -| `src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Analyzer diagnostic definitions | - -## Files Modified - -### Project Files - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Added generator project reference and `InternalsVisibleTo` for generator tests | -| `Directory.Packages.props` | Added `Microsoft.CodeAnalysis.Analyzers` version 3.11.0 | -| `agent-framework-dotnet.slnx` | Added generator project to solution | - -### Obsolete Annotations - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Added `[Obsolete]` attribute with migration guidance | -| `src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Added `[Obsolete]` to both `IMessageHandler` and `IMessageHandler` interfaces | - -### Pragma Suppressions for Internal Obsolete Usage - -| File | Changes | -|------|---------| -| `src/Microsoft.Agents.AI.Workflows/Executor.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/Reflection/RouteBuilderExtensions.cs` | Added `#pragma warning disable CS0618` | -| `src/Microsoft.Agents.AI.Workflows/Reflection/MessageHandlerInfo.cs` | Added `#pragma warning disable CS0618` | - -### Test File Pragma Suppressions - -| File | Changes | -|------|---------| -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/01_Simple_Workflow_Sequential.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/02_Simple_Workflow_Condition.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/03_Simple_Workflow_Loop.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | -| `tests/Microsoft.Agents.AI.Workflows.UnitTests/ReflectionSmokeTest.cs` | Added `#pragma warning disable CS0618` for legacy pattern testing | - -## Attribute Definitions - -### MessageHandlerAttribute - -```csharp -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] -public sealed class MessageHandlerAttribute : Attribute -{ - public Type[]? Yield { get; set; } // Types yielded as workflow outputs - public Type[]? Send { get; set; } // Types sent to other executors -} -``` - -### SendsMessageAttribute - -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class SendsMessageAttribute : Attribute -{ - public Type Type { get; } - public SendsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); -} -``` - -### YieldsMessageAttribute - -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class YieldsMessageAttribute : Attribute -{ - public Type Type { get; } - public YieldsMessageAttribute(Type type) => this.Type = Throw.IfNull(type); -} -``` - -## Diagnostic Rules - -| ID | Severity | Description | -|----|----------|-------------| -| `WFGEN001` | Error | Handler method must have at least 2 parameters (message and IWorkflowContext) | -| `WFGEN002` | Error | Handler method's second parameter must be IWorkflowContext | -| `WFGEN003` | Error | Handler method must return void, ValueTask, or ValueTask | -| `WFGEN004` | Error | Executor class with [MessageHandler] methods must be declared as partial | -| `WFGEN005` | Warning | [MessageHandler] attribute on method in non-Executor class (ignored) | -| `WFGEN006` | Info | ConfigureRoutes already defined manually, [MessageHandler] methods ignored | -| `WFGEN007` | Error | Handler method's third parameter (if present) must be CancellationToken | - -## Handler Signature Support - -The generator supports the following method signatures: - -| Return Type | Parameters | Generated Call | -|-------------|------------|----------------| -| `void` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `void` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `TResult` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `TResult` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext)` | `AddHandler(this.Method)` | -| `ValueTask` | `(TMessage, IWorkflowContext, CancellationToken)` | `AddHandler(this.Method)` | - -## Generated Code Example - -### Input (User Code) - -```csharp -[SendsMessage(typeof(PollToken))] -public partial class MyChatExecutor : Executor -{ - [MessageHandler] - private async ValueTask HandleQueryAsync( - ChatQuery query, IWorkflowContext ctx, CancellationToken ct) - { - return new ChatResponse(...); - } - - [MessageHandler(Yield = new[] { typeof(StreamChunk) }, Send = new[] { typeof(InternalMessage) })] - private void HandleStream(StreamRequest req, IWorkflowContext ctx) - { - // Handler implementation - } -} -``` - -### Output (Generated Code) - -```csharp -// -#nullable enable - -namespace MyNamespace; - -partial class MyChatExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleStream); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(PollToken)); - types.Add(typeof(InternalMessage)); - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(ChatResponse)); - types.Add(typeof(StreamChunk)); - return types; - } -} -``` - -## Build Issues Resolved - -### 1. NU1008 - Central Package Management -Package references in the generator project had inline versions, which conflicts with central package management. Fixed by removing `Version` attributes from `PackageReference` items. - -### 2. RS2008 - Analyzer Release Tracking -Roslyn requires analyzer release tracking documentation. Fixed by adding `$(NoWarn);RS2008` to the generator project. - -### 3. CA1068 - CancellationToken Parameter Order -Method parameters were in wrong order. Fixed by reordering `CancellationToken` to be last. - -### 4. RCS1146 - Conditional Access -Used null check with `&&` instead of `?.` operator. Fixed by using conditional access. - -### 5. CA1310 - StringComparison -`StartsWith(string)` calls without `StringComparison`. Fixed by adding `StringComparison.Ordinal`. - -### 6. CS0103 - Missing Using Directive -Missing `using System;` in SemanticAnalyzer.cs. Fixed by adding the using directive. - -### 7. CS0618 - Obsolete Warnings as Errors -Internal uses of obsolete types caused build failures (TreatWarningsAsErrors). Fixed by adding `#pragma warning disable CS0618` to affected internal files and test files. - -### 8. NU1109 - Package Version Conflict -`Microsoft.CodeAnalysis.Analyzers` 3.3.4 conflicts with `Microsoft.CodeAnalysis.CSharp` 4.14.0 which requires >= 3.11.0. Fixed by updating version to 3.11.0 in `Directory.Packages.props`. - -### 9. RS1041 - Wrong Target Framework for Analyzer -The generator was being multi-targeted due to inherited `TargetFrameworks` from `Directory.Build.props`. Fixed by clearing `TargetFrameworks` and only setting `TargetFramework` to `netstandard2.0`. - -## Migration Guide - -### Before (Reflection-based) - -```csharp -public class MyExecutor : ReflectingExecutor, IMessageHandler -{ - public MyExecutor() : base("MyExecutor") { } - - public ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) - { - // Handler implementation - } -} -``` - -### After (Source Generator) - -```csharp -public partial class MyExecutor : Executor -{ - public MyExecutor() : base("MyExecutor") { } - - [MessageHandler] - private ValueTask HandleAsync(MyMessage message, IWorkflowContext context, CancellationToken ct) - { - // Handler implementation - } -} -``` - -Key migration steps: -1. Change base class from `ReflectingExecutor` to `Executor` -2. Add `partial` modifier to the class -3. Remove `IMessageHandler` interface implementations -4. Add `[MessageHandler]` attribute to handler methods -5. Handler methods can now be any accessibility (private, protected, internal, public) - -## Future Work - -- Create comprehensive unit tests for the source generator -- Add integration tests verifying generated routes match reflection-discovered routes -- Consider adding IDE quick-fix for migrating from `ReflectingExecutor` pattern diff --git a/wf-source-gen-plan.md b/wf-source-gen-plan.md deleted file mode 100644 index e936b538b2..0000000000 --- a/wf-source-gen-plan.md +++ /dev/null @@ -1,293 +0,0 @@ -# Roslyn Source Generator for Workflow Executor Routes - -## Overview - -Replace the reflection-based `ReflectingExecutor` pattern with a compile-time source generator that discovers `[MessageHandler]` attributed methods and generates `ConfigureRoutes`, `ConfigureSentTypes`, and `ConfigureYieldTypes` implementations. - -## Design Decisions (Confirmed) - -- **Attribute syntax**: Inline properties on `[MessageHandler(Yield=[...], Send=[...])]` -- **Class-level attributes**: Generate `ConfigureSentTypes()`/`ConfigureYieldTypes()` from `[SendsMessage]`/`[YieldsMessage]` -- **Migration**: Clean break - requires direct `Executor` inheritance (not `ReflectingExecutor`) -- **Handler accessibility**: Any (private, protected, internal, public) - ---- - -## Implementation Steps - -### Phase 1: Create Source Generator Project - -**1.1 Create project structure:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ -β”œβ”€β”€ Microsoft.Agents.AI.Workflows.Generators.csproj -β”œβ”€β”€ ExecutorRouteGenerator.cs # Main incremental generator -β”œβ”€β”€ Models/ -β”‚ β”œβ”€β”€ ExecutorInfo.cs # Data model for executor analysis -β”‚ └── HandlerInfo.cs # Data model for handler methods -β”œβ”€β”€ Analysis/ -β”‚ β”œβ”€β”€ SyntaxDetector.cs # Syntax-based candidate detection -β”‚ └── SemanticAnalyzer.cs # Semantic model analysis -β”œβ”€β”€ Generation/ -β”‚ └── SourceBuilder.cs # Code generation logic -└── Diagnostics/ - └── DiagnosticDescriptors.cs # Analyzer diagnostics -``` - -**1.2 Project file configuration:** -- Target `netstandard2.0` -- Reference `Microsoft.CodeAnalysis.CSharp` 4.8.0+ -- Set `IsRoslynComponent=true`, `EnforceExtendedAnalyzerRules=true` -- Package as analyzer in `analyzers/dotnet/cs` - -### Phase 2: Define Attributes - -**2.1 Create `MessageHandlerAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] -public sealed class MessageHandlerAttribute : Attribute -{ - public Type[]? Yield { get; set; } // Types yielded as workflow outputs - public Type[]? Send { get; set; } // Types sent to other executors -} -``` - -**2.2 Create `SendsMessageAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class SendsMessageAttribute : Attribute -{ - public Type Type { get; } - public SendsMessageAttribute(Type type) => this.Type = type; -} -``` - -**2.3 Create `YieldsMessageAttribute`:** -``` -dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs -``` -```csharp -[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] -public sealed class YieldsMessageAttribute : Attribute -{ - public Type Type { get; } - public YieldsMessageAttribute(Type type) => this.Type = type; -} -``` - -### Phase 3: Implement Source Generator - -**3.1 Detection criteria (syntax level):** -- Class has `partial` modifier -- Class has at least one method with `[MessageHandler]` attribute - -**3.2 Validation criteria (semantic level):** -- Class derives from `Executor` (directly or transitively) -- Class does NOT already define `ConfigureRoutes` with a body -- Handler method has valid signature: `(TMessage, IWorkflowContext[, CancellationToken])` -- Handler returns `void`, `ValueTask`, or `ValueTask` - -**3.3 Handler signature mapping:** - -| Method Signature | Generated AddHandler Call | -|-----------------|---------------------------| -| `void Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `void Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | -| `TResult Handler(T, IWorkflowContext)` | `AddHandler(this.Handler)` | -| `ValueTask Handler(T, IWorkflowContext, CT)` | `AddHandler(this.Handler)` | - -**3.4 Generated code structure:** -```csharp -// -#nullable enable - -namespace MyNamespace; - -partial class MyExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - // Call base if inheriting from another executor with routes - // routeBuilder = base.ConfigureRoutes(routeBuilder); - - return routeBuilder - .AddHandler(this.Handler1) - .AddHandler(this.Handler2); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(SentType1)); - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(YieldType1)); - return types; - } -} -``` - -**3.5 Inheritance handling:** - -| Scenario | Generated `ConfigureRoutes` | -|----------|----------------------------| -| Directly extends `Executor` | No base call (abstract) | -| Extends executor with `[MessageHandler]` methods | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | -| Extends executor with manual `ConfigureRoutes` | `routeBuilder = base.ConfigureRoutes(routeBuilder);` | - -### Phase 4: Analyzer Diagnostics - -| ID | Severity | Condition | -|----|----------|-----------| -| `WFGEN001` | Error | Handler missing `IWorkflowContext` parameter | -| `WFGEN002` | Error | Handler has invalid return type | -| `WFGEN003` | Error | Executor with `[MessageHandler]` must be `partial` | -| `WFGEN004` | Warning | `[MessageHandler]` on non-Executor class | -| `WFGEN005` | Error | Handler has fewer than 2 parameters | -| `WFGEN006` | Info | `ConfigureRoutes` already defined, handlers ignored | - -### Phase 5: Integration & Migration - -**5.1 Wire generator to main project:** -```xml - - - - -``` - -**5.2 Mark `ReflectingExecutor` obsolete:** -```csharp -[Obsolete("Use [MessageHandler] attribute on methods in a partial class deriving from Executor. " + - "See migration guide. This type will be removed in v1.0.", error: false)] -public class ReflectingExecutor : Executor ... -``` - -**5.3 Mark `IMessageHandler` interfaces obsolete:** -```csharp -[Obsolete("Use [MessageHandler] attribute instead.")] -public interface IMessageHandler { ... } -``` - -### Phase 6: Testing - -**6.1 Generator unit tests:** -``` -dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ -β”œβ”€β”€ ExecutorRouteGeneratorTests.cs -β”œβ”€β”€ SyntaxDetectorTests.cs -β”œβ”€β”€ SemanticAnalyzerTests.cs -└── TestHelpers/ - └── GeneratorTestHelper.cs -``` - -Test cases: -- Simple single handler -- Multiple handlers on one class -- Handlers with different signatures (void, ValueTask, ValueTask) -- Nested classes -- Generic executors -- Inheritance chains (Executor -> CustomBase -> Concrete) -- Class-level `[SendsMessage]`/`[YieldsMessage]` attributes -- Manual `ConfigureRoutes` present (should skip generation) -- Invalid signatures (should produce diagnostics) - -**6.2 Integration tests:** -- Port existing `ReflectingExecutor` test cases to use `[MessageHandler]` -- Verify generated routes match reflection-discovered routes - ---- - -## Files to Create - -| Path | Purpose | -|------|---------| -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Microsoft.Agents.AI.Workflows.Generators.csproj` | Generator project | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs` | Main generator | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ExecutorInfo.cs` | Data model | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/HandlerInfo.cs` | Data model | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SyntaxDetector.cs` | Syntax analysis | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs` | Semantic analysis | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Generation/SourceBuilder.cs` | Code gen | -| `dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Diagnostics/DiagnosticDescriptors.cs` | Diagnostics | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/MessageHandlerAttribute.cs` | Handler attribute | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/SendsMessageAttribute.cs` | Class-level send | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs` | Class-level yield | -| `dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/*.cs` | Generator tests | - -## Files to Modify - -| Path | Changes | -|------|---------| -| `dotnet/src/Microsoft.Agents.AI.Workflows/Microsoft.Agents.AI.Workflows.csproj` | Add generator reference | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/ReflectingExecutor.cs` | Add `[Obsolete]` | -| `dotnet/src/Microsoft.Agents.AI.Workflows/Reflection/IMessageHandler.cs` | Add `[Obsolete]` | -| `dotnet/Microsoft.Agents.sln` | Add new projects | - ---- - -## Example Usage (End State) - -```csharp -[SendsMessage(typeof(PollToken))] -public partial class MyChatExecutor : ChatProtocolExecutor -{ - [MessageHandler] - private async ValueTask HandleQueryAsync( - ChatQuery query, IWorkflowContext ctx, CancellationToken ct) - { - // Return type automatically inferred as output - return new ChatResponse(...); - } - - [MessageHandler(Yield = [typeof(StreamChunk)], Send = [typeof(InternalMessage)])] - private void HandleStream(StreamRequest req, IWorkflowContext ctx) - { - // Explicit Yield/Send for complex handlers - } -} -``` - -Generated: -```csharp -partial class MyChatExecutor -{ - protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) - { - routeBuilder = base.ConfigureRoutes(routeBuilder); - return routeBuilder - .AddHandler(this.HandleQueryAsync) - .AddHandler(this.HandleStream); - } - - protected override ISet ConfigureSentTypes() - { - var types = base.ConfigureSentTypes(); - types.Add(typeof(PollToken)); - types.Add(typeof(InternalMessage)); // From handler attribute - return types; - } - - protected override ISet ConfigureYieldTypes() - { - var types = base.ConfigureYieldTypes(); - types.Add(typeof(ChatResponse)); // From return type - types.Add(typeof(StreamChunk)); // From handler attribute - return types; - } -} -```