Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
2 changes: 2 additions & 0 deletions dotnet/agent-framework-dotnet.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,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/" />
Expand Down Expand Up @@ -435,6 +436,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>

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to localize these strings, right?

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));
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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.IsNullOrEmpty(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.IsNullOrEmpty(info.Namespace))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Probably better to use IsNullOrWhitespace or is whitespace a valid 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.IsNullOrEmpty(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();
}
}
Loading
Loading