mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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
This commit is contained in:
committed by
GitHub
Unverified
parent
c1786b38a7
commit
939c2d69f9
@@ -17,10 +17,14 @@ public static class AIAgentExtensions
|
||||
/// <param name="services">The service provider.</param>
|
||||
/// <returns>The durable agent proxy.</returns>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when the agent is a DurableAIAgent instance or if the agent has no name.
|
||||
/// Thrown when the agent is a <see cref="DurableAIAgent"/> instance or if the agent has no name.
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown if <paramref name="services"/> does not contain an <see cref="IDurableAgentClient"/>.
|
||||
/// Thrown if <paramref name="services"/> does not contain an <see cref="IDurableAgentClient"/>
|
||||
/// or if durable agents have not been configured on the service collection.
|
||||
/// </exception>
|
||||
/// <exception cref="AgentNotRegisteredException">
|
||||
/// Thrown when the agent with the specified name has not been registered.
|
||||
/// </exception>
|
||||
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<IDurableAgentClient>();
|
||||
return new DurableAIAgentProxy(agentName, agentClient);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when an agent with the specified name has not been registered.
|
||||
/// </summary>
|
||||
public sealed class AgentNotRegisteredException : InvalidOperationException
|
||||
{
|
||||
// Not used, but required by static analysis.
|
||||
private AgentNotRegisteredException()
|
||||
{
|
||||
this.AgentName = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentNotRegisteredException"/> class with the agent name.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent that was not registered.</param>
|
||||
public AgentNotRegisteredException(string agentName)
|
||||
: base(GetMessage(agentName))
|
||||
{
|
||||
this.AgentName = agentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentNotRegisteredException"/> class with the agent name and an inner exception.
|
||||
/// </summary>
|
||||
/// <param name="agentName">The name of the agent that was not registered.</param>
|
||||
/// <param name="innerException">The exception that is the cause of the current exception.</param>
|
||||
public AgentNotRegisteredException(string agentName, Exception? innerException)
|
||||
: base(GetMessage(agentName), innerException)
|
||||
{
|
||||
this.AgentName = agentName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the agent that was not registered.
|
||||
/// </summary>
|
||||
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.";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <param name="options">Optional run options.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>The response from the agent.</returns>
|
||||
/// <exception cref="AgentNotRegisteredException">Thrown when the agent has not been registered.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when the provided thread is not valid for a durable agent.</exception>
|
||||
/// <exception cref="NotSupportedException">Thrown when cancellation is requested (cancellation is not supported for durable agents).</exception>
|
||||
public override async Task<AgentRunResponse> RunAsync(
|
||||
IEnumerable<ChatMessage> 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<AgentRunResponse>(durableThread.SessionId, nameof(AgentEntity.RunAgentAsync), request);
|
||||
try
|
||||
{
|
||||
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(
|
||||
durableThread.SessionId,
|
||||
nameof(AgentEntity.RunAgentAsync),
|
||||
request);
|
||||
}
|
||||
catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound")
|
||||
{
|
||||
throw new AgentNotRegisteredException(this._agentName, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -80,7 +80,7 @@ public static class ServiceCollectionExtensions
|
||||
DurableAgentsOptions options = new();
|
||||
configure(options);
|
||||
|
||||
var agents = options.GetAgentFactories();
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that an agent with the specified name has been registered.
|
||||
/// </summary>
|
||||
/// <param name="services">The service provider.</param>
|
||||
/// <param name="agentName">The name of the agent to validate.</param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when the agent dictionary is not registered in the service provider.
|
||||
/// </exception>
|
||||
/// <exception cref="AgentNotRegisteredException">
|
||||
/// Thrown when the agent with the specified name has not been registered.
|
||||
/// </exception>
|
||||
internal static void ValidateAgentIsRegistered(IServiceProvider services, string agentName)
|
||||
{
|
||||
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>? agents =
|
||||
services.GetService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>()
|
||||
?? 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.
|
||||
|
||||
@@ -21,6 +21,12 @@ public static class DurableTaskClientExtensions
|
||||
/// <returns>A durable agent proxy.</returns>
|
||||
/// <exception cref="ArgumentNullException">Thrown when <paramref name="durableClient"/> or <paramref name="context"/> is null.</exception>
|
||||
/// <exception cref="ArgumentException">Thrown when <paramref name="agentName"/> is null or empty.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when durable agents have not been configured on the service collection.
|
||||
/// </exception>
|
||||
/// <exception cref="AgentNotRegisteredException">
|
||||
/// Thrown when the agent has not been registered.
|
||||
/// </exception>
|
||||
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<DefaultDurableAgentClient>(
|
||||
context.InstanceServices,
|
||||
durableClient);
|
||||
|
||||
@@ -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<AgentNotRegisteredException>(
|
||||
() => unregisteredAgent.AsDurableAgentProxy(testHelper.Services));
|
||||
|
||||
Assert.Equal("UnregisteredAgent", exception.AgentName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for orchestration execution scenarios with Durable Task Agents.
|
||||
/// </summary>
|
||||
[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<string> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user