From 939c2d69f91eb44a7071adf33f9cdead30adc22d Mon Sep 17 00:00:00 2001 From: Chris Gillum Date: Fri, 14 Nov 2025 11:28:07 -0800 Subject: [PATCH] .NET: Friendly error message when durable agent isn't registered (#2214) * .NET: Friendly error message when durable agent isn't registered * Updates * Fix file encoding * Add validation for durable agent proxies * Copilot PR feedback --- .../AIAgentExtensions.cs | 12 ++- .../AgentNotRegisteredException.cs | 47 +++++++++ .../DurableAIAgent.cs | 16 +++- .../ServiceCollectionExtensions.cs | 26 ++++- .../DurableTaskClientExtensions.cs | 9 ++ .../ExternalClientTests.cs | 22 +++++ .../OrchestrationTests.cs | 95 +++++++++++++++++++ 7 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs index 4f5619ac76..5eac1b84e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AIAgentExtensions.cs @@ -17,10 +17,14 @@ public static class AIAgentExtensions /// The service provider. /// The durable agent proxy. /// - /// Thrown when the agent is a DurableAIAgent instance or if the agent has no name. + /// Thrown when the agent is a instance or if the agent has no name. /// /// - /// Thrown if does not contain an . + /// Thrown if does not contain an + /// or if durable agents have not been configured on the service collection. + /// + /// + /// Thrown when the agent with the specified name has not been registered. /// public static AIAgent AsDurableAgentProxy(this AIAgent agent, IServiceProvider services) { @@ -33,6 +37,10 @@ public static class AIAgentExtensions } string agentName = agent.Name ?? throw new ArgumentException("Agent must have a name.", nameof(agent)); + + // Validate that the agent is registered + ServiceCollectionExtensions.ValidateAgentIsRegistered(services, agentName); + IDurableAgentClient agentClient = services.GetRequiredService(); return new DurableAIAgentProxy(agentName, agentClient); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs new file mode 100644 index 0000000000..fc051fa0b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/AgentNotRegisteredException.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.DurableTask; + +/// +/// Exception thrown when an agent with the specified name has not been registered. +/// +public sealed class AgentNotRegisteredException : InvalidOperationException +{ + // Not used, but required by static analysis. + private AgentNotRegisteredException() + { + this.AgentName = string.Empty; + } + + /// + /// Initializes a new instance of the class with the agent name. + /// + /// The name of the agent that was not registered. + public AgentNotRegisteredException(string agentName) + : base(GetMessage(agentName)) + { + this.AgentName = agentName; + } + + /// + /// Initializes a new instance of the class with the agent name and an inner exception. + /// + /// The name of the agent that was not registered. + /// The exception that is the cause of the current exception. + public AgentNotRegisteredException(string agentName, Exception? innerException) + : base(GetMessage(agentName), innerException) + { + this.AgentName = agentName; + } + + /// + /// Gets the name of the agent that was not registered. + /// + public string AgentName { get; } + + private static string GetMessage(string agentName) + { + ArgumentException.ThrowIfNullOrEmpty(agentName); + return $"No agent named '{agentName}' was registered. Ensure the agent is registered using {nameof(ServiceCollectionExtensions.ConfigureDurableAgents)} before using it in an orchestration."; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index 52907c5816..1a117aff14 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -5,6 +5,7 @@ using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization.Metadata; using Microsoft.DurableTask; +using Microsoft.DurableTask.Entities; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.DurableTask; @@ -59,6 +60,9 @@ public sealed class DurableAIAgent : AIAgent /// Optional run options. /// The cancellation token. /// The response from the agent. + /// Thrown when the agent has not been registered. + /// Thrown when the provided thread is not valid for a durable agent. + /// Thrown when cancellation is requested (cancellation is not supported for durable agents). public override async Task RunAsync( IEnumerable messages, AgentThread? thread = null, @@ -95,7 +99,17 @@ public sealed class DurableAIAgent : AIAgent } RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames); - return await this._context.Entities.CallEntityAsync(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request); + try + { + return await this._context.Entities.CallEntityAsync( + durableThread.SessionId, + nameof(AgentEntity.RunAgentAsync), + request); + } + catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound") + { + throw new AgentNotRegisteredException(this._agentName, e); + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs index c611206d6a..2f435e0541 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/ServiceCollectionExtensions.cs @@ -80,7 +80,7 @@ public static class ServiceCollectionExtensions DurableAgentsOptions options = new(); configure(options); - var agents = options.GetAgentFactories(); + IReadOnlyDictionary> agents = options.GetAgentFactories(); // The agent dictionary contains the real agent factories, which is used by the agent entities. services.AddSingleton(agents); @@ -98,6 +98,30 @@ public static class ServiceCollectionExtensions return options; } + /// + /// Validates that an agent with the specified name has been registered. + /// + /// The service provider. + /// The name of the agent to validate. + /// + /// Thrown when the agent dictionary is not registered in the service provider. + /// + /// + /// Thrown when the agent with the specified name has not been registered. + /// + internal static void ValidateAgentIsRegistered(IServiceProvider services, string agentName) + { + IReadOnlyDictionary>? agents = + services.GetService>>() + ?? throw new InvalidOperationException( + $"Durable agents have not been configured. Ensure {nameof(ConfigureDurableAgents)} has been called on the service collection."); + + if (!agents.ContainsKey(agentName)) + { + throw new AgentNotRegisteredException(agentName); + } + } + private sealed class DefaultDataConverter : DataConverter { // Use durable agent options (web defaults + camel case by default) with case-insensitive matching. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs index 7628a3f3bf..0977d756cb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureFunctions/DurableTaskClientExtensions.cs @@ -21,6 +21,12 @@ public static class DurableTaskClientExtensions /// A durable agent proxy. /// Thrown when or is null. /// Thrown when is null or empty. + /// + /// Thrown when durable agents have not been configured on the service collection. + /// + /// + /// Thrown when the agent has not been registered. + /// public static AIAgent AsDurableAgentProxy( this DurableTaskClient durableClient, FunctionContext context, @@ -30,6 +36,9 @@ public static class DurableTaskClientExtensions ArgumentNullException.ThrowIfNull(context); ArgumentException.ThrowIfNullOrEmpty(agentName); + // Validate that the agent is registered + DurableTask.ServiceCollectionExtensions.ValidateAgentIsRegistered(context.InstanceServices, agentName); + DefaultDurableAgentClient agentClient = ActivatorUtilities.CreateInstance( context.InstanceServices, durableClient); diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs index 241e05a843..ad57ea9a52 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs @@ -212,4 +212,26 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo Assert.NotEmpty(response.Text); Assert.Contains("John Doe", response.Text); } + + [Fact] + public void AsDurableAgentProxy_ThrowsWhenAgentNotRegistered() + { + // Setup: Register one agent but try to use a different one + AIAgent registeredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a helpful assistant.", + name: "RegisteredAgent"); + + using TestHelper testHelper = TestHelper.Start([registeredAgent], this._outputHelper); + + // Create an agent with a different name that isn't registered + AIAgent unregisteredAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + instructions: "You are a helpful assistant.", + name: "UnregisteredAgent"); + + // Act & Assert: Should throw AgentNotRegisteredException + AgentNotRegisteredException exception = Assert.Throws( + () => unregisteredAgent.AsDurableAgentProxy(testHelper.Services)); + + Assert.Equal("UnregisteredAgent", exception.AgentName); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs new file mode 100644 index 0000000000..6b905f2623 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/OrchestrationTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; +using System.Reflection; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using OpenAI; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.DurableTask.IntegrationTests; + +/// +/// Tests for orchestration execution scenarios with Durable Task Agents. +/// +[Collection("Sequential")] +[Trait("Category", "Integration")] +public sealed class OrchestrationTests(ITestOutputHelper outputHelper) : IDisposable +{ + private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached + ? TimeSpan.FromMinutes(5) + : TimeSpan.FromSeconds(30); + + private static readonly IConfiguration s_configuration = + new ConfigurationBuilder() + .AddUserSecrets(Assembly.GetExecutingAssembly()) + .AddEnvironmentVariables() + .Build(); + + private readonly ITestOutputHelper _outputHelper = outputHelper; + private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout); + + private CancellationToken TestTimeoutToken => this._cts.Token; + + public void Dispose() => this._cts.Dispose(); + + [Fact] + public async Task GetAgent_ThrowsWhenAgentNotRegisteredAsync() + { + // Define an orchestration that tries to use an unregistered agent + static async Task TestOrchestrationAsync(TaskOrchestrationContext context) + { + // Get an agent that hasn't been registered + DurableAIAgent agent = context.GetAgent("NonExistentAgent"); + + // This should throw when RunAsync is called because the agent doesn't exist + await agent.RunAsync("Hello"); + return "Should not reach here"; + } + + // Setup: Create test helper without registering "NonExistentAgent" + using TestHelper testHelper = TestHelper.Start( + this._outputHelper, + configureAgents: agents => + { + // Register a different agent, but not "NonExistentAgent" + agents.AddAIAgentFactory( + "OtherAgent", + sp => TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent( + name: "OtherAgent", + instructions: "You are a test agent.")); + }, + durableTaskRegistry: registry => + registry.AddOrchestratorFunc( + name: nameof(TestOrchestrationAsync), + orchestrator: TestOrchestrationAsync)); + + DurableTaskClient client = testHelper.GetClient(); + + // Act: Start the orchestration + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync( + orchestratorName: nameof(TestOrchestrationAsync), + cancellation: this.TestTimeoutToken); + + // Wait for the orchestration to complete and check for failure + OrchestrationMetadata status = await client.WaitForInstanceCompletionAsync( + instanceId, + getInputsAndOutputs: true, + this.TestTimeoutToken); + + // Assert: Verify the orchestration failed with the expected exception + Assert.NotNull(status); + Assert.Equal(OrchestrationRuntimeStatus.Failed, status.RuntimeStatus); + Assert.NotNull(status.FailureDetails); + + // Verify the exception type is AgentNotRegisteredException + Assert.True( + status.FailureDetails.ErrorType == typeof(AgentNotRegisteredException).FullName, + $"Expected AgentNotRegisteredException but got ErrorType: {status.FailureDetails.ErrorType}, Message: {status.FailureDetails.ErrorMessage}"); + + // Verify the exception message contains the agent name + Assert.Contains("NonExistentAgent", status.FailureDetails.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } +}