diff --git a/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs index e2dec8505b..41b622fdad 100644 --- a/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs +++ b/dotnet/samples/03-workflows/Agents/CustomAgentExecutors/Program.cs @@ -61,6 +61,12 @@ private static async Task Main() { Console.WriteLine($"{outputEvent}"); } + + if (evt is WorkflowErrorEvent errorEvent) + { + Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}"); + Console.WriteLine($"Details: {errorEvent.Exception}"); + } } } } @@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve /// /// A custom executor that uses an AI agent to provide feedback on a slogan. /// -internal sealed class FeedbackExecutor : Executor +[SendsMessage(typeof(FeedbackResult))] +[YieldsOutput(typeof(string))] +internal sealed partial class FeedbackExecutor : Executor { private readonly AIAgent _agent; private AgentSession? _session; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index b62377a971..66b67bffdf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -68,7 +68,7 @@ public static MethodAnalysisResult AnalyzeHandlerMethod( string classKey = GetClassKey(classSymbol); bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); - bool configureProtocol = HasConfigureProtocolDefined(classSymbol); + bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); // Extract class metadata string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true @@ -97,7 +97,7 @@ public static MethodAnalysisResult AnalyzeHandlerMethod( return new MethodAnalysisResult( classKey, @namespace, className, genericParameters, isNested, containingTypeChain, baseHasConfigureProtocol, classSendTypes, classYieldTypes, - isPartialClass, derivesFromExecutor, configureProtocol, + isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, classLocation, handler, Diagnostics: new ImmutableEquatableArray(methodDiagnostics.ToImmutable())); @@ -149,7 +149,7 @@ public static AnalysisResult CombineHandlerMethodResults(IEnumerable AnalyzeClassProtocolAttribute( bool isPartialClass = IsPartialClass(classSymbol, cancellationToken); bool derivesFromExecutor = DerivesFromExecutor(classSymbol); bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol); + bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol); string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true ? null @@ -241,6 +242,7 @@ public static ImmutableArray AnalyzeClassProtocolAttribute( isPartialClass, derivesFromExecutor, hasManualConfigureProtocol, + baseHasConfigureProtocol, classLocation, typeName, attributeKind)); @@ -321,7 +323,7 @@ public static AnalysisResult CombineOutputOnlyResults(IEnumerable.Empty, ClassSendTypes: new ImmutableEquatableArray(sendTypes.ToImmutable()), ClassYieldTypes: new ImmutableEquatableArray(yieldTypes.ToImmutable())); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs index df9205cc5f..1039855ea5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/ClassProtocolInfo.cs @@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// /// 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). +/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented). /// /// Unique identifier for the class (fully qualified name). /// The namespace of the class. @@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// The chain of containing types for nested classes. Empty if not nested. /// Whether the class is declared as partial. /// Whether the class derives from Executor. -/// Whether the class has a manually defined ConfigureRoutes method. +/// Whether the class has a manually defined ConfigureProtocol method. +/// Whether a base class already overrides ConfigureProtocol. /// Location info for diagnostics. /// The fully qualified type name from the attribute. /// Whether this is from a SendsMessage or YieldsOutput attribute. @@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo( string ContainingTypeChain, bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, + bool BaseHasConfigureProtocol, DiagnosticLocationInfo? ClassLocation, string TypeName, ProtocolAttributeKind AttributeKind) @@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo( /// public static ClassProtocolInfo Empty { get; } = new( string.Empty, null, string.Empty, null, false, string.Empty, - false, false, false, null, string.Empty, ProtocolAttributeKind.Send); + false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs index fb3fafc6c2..4b6df3f7a5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Models/MethodAnalysisResult.cs @@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models; /// Uses value-equatable types to support incremental generator caching. /// /// -/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes) +/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol) /// is extracted here but validated once per class in CombineMethodResults to avoid /// redundant validation work when a class has multiple handlers. /// @@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult( // Class-level facts (used for validation in CombineMethodResults) bool IsPartialClass, bool DerivesFromExecutor, - bool HasManualConfigureRoutes, + bool HasManualConfigureProtocol, // Class location for diagnostics (value-equatable) DiagnosticLocationInfo? ClassLocation, diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs index d2160486cc..8433dd5e3e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/ExecutorRouteGeneratorTests.cs @@ -651,7 +651,7 @@ private void HandleFromFile2(int message, IWorkflowContext context) { } } [Fact] - public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides() + public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides() { // File 1: Partial with one handler var file1 = """ @@ -700,7 +700,7 @@ private void HandleFromFile2(int message, IWorkflowContext context) { } generated.Should().RegisterSentMessageType("string") .And.RegisterSentMessageType("int") .And.RegisterYieldedOutputType("string") - .And.RegisterYieldedOutputType("string"); + .And.RegisterYieldedOutputType("int"); } #endregion @@ -1046,6 +1046,85 @@ public GenericExecutor() : base("generic") { } .And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage"); } + [Fact] + public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall() + { + // A protocol-only partial executor deriving from Executor + // has a base class that already overrides ConfigureProtocol. The generator must emit + // "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations + // are preserved — not "return protocolBuilder" which silently drops them. + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class FeedbackResult { } + + [SendsMessage(typeof(FeedbackResult))] + [YieldsOutput(typeof(string))] + public partial class FeedbackExecutor : Executor + { + public FeedbackExecutor() : base("feedback") { } + + public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default) + => default; + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Base class Executor overrides ConfigureProtocol, so the generated override + // must chain to base to preserve the inherited handler registration. + generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)", + because: "Executor overrides ConfigureProtocol, so base must be called to preserve its handler registration"); + generated.Should().Contain(".SendsMessage()"); + generated.Should().Contain(".YieldsOutput()"); + } + + [Fact] + public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall() + { + // A protocol-only partial executor deriving directly from Executor (abstract base + // with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder" + // rather than "return base.ConfigureProtocol(protocolBuilder)". + var source = """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Agents.AI.Workflows; + + namespace TestNamespace; + + public class BroadcastMessage { } + + [SendsMessage(typeof(BroadcastMessage))] + public partial class BroadcastExecutor : Executor + { + public BroadcastExecutor() : base("broadcast") { } + } + """; + + var result = GeneratorTestHelper.RunGenerator(source); + + result.RunResult.GeneratedTrees.Should().HaveCount(1); + result.RunResult.Diagnostics.Should().BeEmpty(); + + var generated = result.RunResult.GeneratedTrees[0].ToString(); + + // Executor's ConfigureProtocol is abstract — no base call needed. + generated.Should().Contain("return protocolBuilder", + because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed"); + generated.Should().NotContain("base.ConfigureProtocol"); + } + #endregion #region Generic Executor Tests