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