diff --git a/samples/AgentServer/EchoAgent.cs b/samples/AgentServer/EchoAgent.cs index 6729ef93..ed76c1d7 100644 --- a/samples/AgentServer/EchoAgent.cs +++ b/samples/AgentServer/EchoAgent.cs @@ -7,7 +7,6 @@ public class EchoAgent public void Attach(ITaskManager taskManager) { taskManager.OnMessageReceived = ProcessMessageAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; } private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken) @@ -34,29 +33,26 @@ private Task ProcessMessageAsync(MessageSendParams messageSendParam return Task.FromResult(message); } - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + public AgentCard Card { - if (cancellationToken.IsCancellationRequested) + get { - return Task.FromCanceled(cancellationToken); + var capabilities = new AgentCapabilities() + { + Streaming = true, + PushNotifications = false, + }; + + return new AgentCard() + { + Name = "Echo Agent", + Description = "Agent which will echo every message it receives.", + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = capabilities, + Skills = [], + }; } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "Echo Agent", - Description = "Agent which will echo every message it receives.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); } } \ No newline at end of file diff --git a/samples/AgentServer/EchoAgentWithTasks.cs b/samples/AgentServer/EchoAgentWithTasks.cs index 2e83bfcd..7f6669a5 100644 --- a/samples/AgentServer/EchoAgentWithTasks.cs +++ b/samples/AgentServer/EchoAgentWithTasks.cs @@ -12,7 +12,6 @@ public void Attach(ITaskManager taskManager) _taskManager = taskManager; taskManager.OnTaskCreated = ProcessMessageAsync; taskManager.OnTaskUpdated = ProcessMessageAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; } private async Task ProcessMessageAsync(AgentTask task, CancellationToken cancellationToken) @@ -41,30 +40,27 @@ private async Task ProcessMessageAsync(AgentTask task, CancellationToken cancell cancellationToken: cancellationToken); } - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + public AgentCard Card { - if (cancellationToken.IsCancellationRequested) + get { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; + var capabilities = new AgentCapabilities() + { + Streaming = true, + PushNotifications = false, + }; - return Task.FromResult(new AgentCard() - { - Name = "Echo Agent", - Description = "Agent which will echo every message it receives.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); + return new AgentCard() + { + Name = "Echo Agent", + Description = "Agent which will echo every message it receives.", + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = capabilities, + Skills = [], + }; + } } private static TaskState? GetTargetStateFromMetadata(Dictionary? metadata) diff --git a/samples/AgentServer/Program.cs b/samples/AgentServer/Program.cs index fa104348..e6de58ce 100644 --- a/samples/AgentServer/Program.cs +++ b/samples/AgentServer/Program.cs @@ -42,7 +42,7 @@ var echoAgent = new EchoAgent(); echoAgent.Attach(taskManager); app.MapA2A(taskManager, "/echo"); - app.MapWellKnownAgentCard(taskManager, "/echo"); + app.MapWellKnownAgentCard(echoAgent.Card, "/echo"); app.MapHttpA2A(taskManager, "/echo"); break; @@ -50,7 +50,7 @@ var echoAgentWithTasks = new EchoAgentWithTasks(); echoAgentWithTasks.Attach(taskManager); app.MapA2A(taskManager, "/echotasks"); - app.MapWellKnownAgentCard(taskManager, "/echotasks"); + app.MapWellKnownAgentCard(echoAgentWithTasks.Card, "/echotasks"); app.MapHttpA2A(taskManager, "/echotasks"); break; @@ -58,14 +58,14 @@ var researcherAgent = new ResearcherAgent(); researcherAgent.Attach(taskManager); app.MapA2A(taskManager, "/researcher"); - app.MapWellKnownAgentCard(taskManager, "/researcher"); + app.MapWellKnownAgentCard(researcherAgent.Card, "/researcher"); break; case "speccompliance": var specComplianceAgent = new SpecComplianceAgent(); specComplianceAgent.Attach(taskManager); app.MapA2A(taskManager, "/speccompliance"); - app.MapWellKnownAgentCard(taskManager, "/speccompliance"); + app.MapWellKnownAgentCard(specComplianceAgent.Card, "/speccompliance"); break; default: diff --git a/samples/AgentServer/ResearcherAgent.cs b/samples/AgentServer/ResearcherAgent.cs index 6b5408f6..b7bfcf33 100644 --- a/samples/AgentServer/ResearcherAgent.cs +++ b/samples/AgentServer/ResearcherAgent.cs @@ -32,7 +32,6 @@ public void Attach(ITaskManager taskManager) var message = ((TextPart?)task.History?.Last()?.Parts?.FirstOrDefault())?.Text ?? string.Empty; await InvokeAsync(task.Id, message, cancellationToken); }; - _taskManager.OnAgentCardQuery = GetAgentCardAsync; } // This is the main entry point for the agent. It is called when a task is created or updated. @@ -143,29 +142,26 @@ await _taskManager.ReturnArtifactAsync( _agentStates[taskId] = AgentState.WaitingForFeedbackOnPlan; } - private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + public AgentCard Card { - if (cancellationToken.IsCancellationRequested) + get { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; + var capabilities = new AgentCapabilities() + { + Streaming = true, + PushNotifications = false, + }; - return Task.FromResult(new AgentCard() - { - Name = "Researcher Agent", - Description = "Agent which conducts research.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); + return new AgentCard() + { + Name = "Researcher Agent", + Description = "Agent which conducts research.", + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = capabilities, + Skills = [], + }; + } } } \ No newline at end of file diff --git a/samples/AgentServer/SpecComplianceAgent.cs b/samples/AgentServer/SpecComplianceAgent.cs index 9aacf602..926cc9d3 100644 --- a/samples/AgentServer/SpecComplianceAgent.cs +++ b/samples/AgentServer/SpecComplianceAgent.cs @@ -8,7 +8,6 @@ public class SpecComplianceAgent public void Attach(ITaskManager taskManager) { - taskManager.OnAgentCardQuery = GetAgentCard; taskManager.OnTaskCreated = OnTaskCreatedAsync; taskManager.OnTaskUpdated = OnTaskUpdatedAsync; _taskManager = taskManager; @@ -39,29 +38,26 @@ private async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancella } } - private Task GetAgentCard(string agentUrl, CancellationToken cancellationToken) + public AgentCard Card { - if (cancellationToken.IsCancellationRequested) + get { - return Task.FromCanceled(cancellationToken); + var capabilities = new AgentCapabilities() + { + Streaming = true, + PushNotifications = false, + }; + + return new AgentCard() + { + Name = "A2A Specification Compliance Agent", + Description = "Agent to run A2A specification compliance tests.", + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = capabilities, + Skills = [], + }; } - - var capabilities = new AgentCapabilities() - { - Streaming = true, - PushNotifications = false, - }; - - return Task.FromResult(new AgentCard() - { - Name = "A2A Specification Compliance Agent", - Description = "Agent to run A2A specification compliance tests.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [], - }); } } diff --git a/samples/SemanticKernelAgent/Program.cs b/samples/SemanticKernelAgent/Program.cs index 4636be24..6dac0c68 100644 --- a/samples/SemanticKernelAgent/Program.cs +++ b/samples/SemanticKernelAgent/Program.cs @@ -35,6 +35,6 @@ var taskManager = new TaskManager(); agent.Attach(taskManager); app.MapA2A(taskManager, string.Empty); -app.MapWellKnownAgentCard(taskManager, string.Empty); +app.MapWellKnownAgentCard(SemanticKernelTravelAgent.Card, string.Empty); await app.RunAsync(); diff --git a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs index 8307bc21..196e8c2c 100644 --- a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs +++ b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs @@ -140,7 +140,6 @@ public void Attach(ITaskManager taskManager) _taskManager = taskManager; taskManager.OnTaskCreated = ExecuteAgentTaskAsync; taskManager.OnTaskUpdated = ExecuteAgentTaskAsync; - taskManager.OnAgentCardQuery = GetAgentCardAsync; } public async Task ExecuteAgentTaskAsync(AgentTask task, CancellationToken cancellationToken) @@ -168,43 +167,40 @@ public async Task ExecuteAgentTaskAsync(AgentTask task, CancellationToken cancel await _taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, cancellationToken: cancellationToken); } - public static Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken) + public static AgentCard Card { - if (cancellationToken.IsCancellationRequested) + get { - return Task.FromCanceled(cancellationToken); - } - - var capabilities = new AgentCapabilities() - { - Streaming = false, - PushNotifications = false, - }; + var capabilities = new AgentCapabilities() + { + Streaming = false, + PushNotifications = false, + }; - var skillTripPlanning = new AgentSkill() - { - Id = "trip_planning_sk", - Name = "Semantic Kernel Trip Planning", - Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.", - Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"], - Examples = - [ - "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.", + var skillTripPlanning = new AgentSkill() + { + Id = "trip_planning_sk", + Name = "Semantic Kernel Trip Planning", + Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.", + Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"], + Examples = + [ + "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.", "I am from Korea. What's the exchange rate and recommended itinerary for visiting Galway?", ], - }; + }; - return Task.FromResult(new AgentCard() - { - Name = "SK Travel Agent", - Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.", - Url = agentUrl, - Version = "1.0.0", - DefaultInputModes = ["text"], - DefaultOutputModes = ["text"], - Capabilities = capabilities, - Skills = [skillTripPlanning], - }); + return new AgentCard() + { + Name = "SK Travel Agent", + Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.", + Version = "1.0.0", + DefaultInputModes = ["text"], + DefaultOutputModes = ["text"], + Capabilities = capabilities, + Skills = [skillTripPlanning], + }; + } } #region private diff --git a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs index a91e58da..c0ff438c 100644 --- a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs +++ b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -46,22 +46,51 @@ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpo /// Enables the well-known agent card endpoint for agent discovery. /// /// The endpoint route builder to configure. - /// The task manager for handling A2A operations. + /// The to be configured. + /// The base path where the A2A agent is hosted. + /// An endpoint convention builder for further configuration. + public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, AgentCard agentCard, [StringSyntax("Route")] string agentPath) + { + ArgumentNullException.ThrowIfNull(agentCard); + + return MapWellKnownAgentCard(endpoints, new AgentCardProvider(agentCard), agentPath); + } + + /// + /// Enables the well-known agent card endpoint for agent discovery. + /// + /// The endpoint route builder to configure. + /// The provider. /// The base path where the A2A agent is hosted. /// An endpoint convention builder for further configuration. - public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string agentPath) + public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, AgentCardProvider agentCardProvider, [StringSyntax("Route")] string agentPath) { ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(taskManager); + ArgumentNullException.ThrowIfNull(agentCardProvider); ArgumentException.ThrowIfNullOrEmpty(agentPath); var routeGroup = endpoints.MapGroup(""); + var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + routeGroup.MapGet(".well-known/agent-card.json", async (HttpRequest request, CancellationToken cancellationToken) => { - var agentUrl = $"{request.Scheme}://{request.Host}{agentPath}"; - var agentCard = await taskManager.OnAgentCardQuery(agentUrl, cancellationToken); - return Results.Ok(agentCard); + using var activity = ActivitySource.StartActivity("GetAgentCard", ActivityKind.Server); + + UriBuilder uriBuilder = new(request.Scheme, request.Host.Host, request.Host.Port ?? -1, agentPath); + + try + { + return Results.Ok(await agentCardProvider.GetAgentCardAsync(uriBuilder.ToString(), cancellationToken)); + } + catch (Exception ex) + { +#pragma warning disable CA1848, EA0000 // There are no args to format here so there are no gains from using source generators. + logger.LogError(ex, "Unexpected error when fetching AgentCard"); +#pragma warning restore CA1848, EA0000 + return Results.Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } }); return routeGroup; @@ -85,10 +114,6 @@ public static IEndpointConventionBuilder MapHttpA2A(this IEndpointRouteBuilder e var routeGroup = endpoints.MapGroup(path); - // /v1/card endpoint - Agent discovery - routeGroup.MapGet("/v1/card", async (HttpRequest request, CancellationToken cancellationToken) => - await A2AHttpProcessor.GetAgentCardAsync(taskManager, logger, $"{request.Scheme}://{request.Host}{path}", cancellationToken).ConfigureAwait(false)); - // /v1/tasks/{id} endpoint routeGroup.MapGet("/v1/tasks/{id}", (string id, [FromQuery] int? historyLength, [FromQuery] string? metadata, CancellationToken cancellationToken) => A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, metadata, cancellationToken)); diff --git a/src/A2A.AspNetCore/A2AHttpProcessor.cs b/src/A2A.AspNetCore/A2AHttpProcessor.cs index eb3a95d1..f9d8c221 100644 --- a/src/A2A.AspNetCore/A2AHttpProcessor.cs +++ b/src/A2A.AspNetCore/A2AHttpProcessor.cs @@ -22,25 +22,6 @@ internal static class A2AHttpProcessor /// public static readonly ActivitySource ActivitySource = new("A2A.HttpProcessor", "1.0.0"); - /// - /// Processes a request to retrieve the agent card containing agent capabilities and metadata. - /// - /// - /// Invokes the task manager's agent card query handler to get current agent information. - /// - /// The task manager instance containing the agent card query handler. - /// Logger instance for recording operation details and errors. - /// The URL of the agent to retrieve the card for. - /// A cancellation token that can be used to cancel the operation. - /// An HTTP result containing the agent card JSON or an error response. - internal static Task GetAgentCardAsync(ITaskManager taskManager, ILogger logger, string agentUrl, CancellationToken cancellationToken) - => WithExceptionHandlingAsync(logger, "GetAgentCard", async ct => - { - var agentCard = await taskManager.OnAgentCardQuery(agentUrl, ct); - - return Results.Ok(agentCard); - }, cancellationToken: cancellationToken); - /// /// Processes a request to retrieve a specific task by its ID. /// diff --git a/src/A2A/Models/AgentCapabilities.cs b/src/A2A/Models/AgentCapabilities.cs index 4c410cca..b5b91f4e 100644 --- a/src/A2A/Models/AgentCapabilities.cs +++ b/src/A2A/Models/AgentCapabilities.cs @@ -7,6 +7,21 @@ namespace A2A; /// public sealed class AgentCapabilities { + /// + /// Creates a new instance of the class. + /// + public AgentCapabilities() + { + } + + internal AgentCapabilities(AgentCapabilities source) + { + Streaming = source.Streaming; + PushNotifications = source.PushNotifications; + StateTransitionHistory = source.StateTransitionHistory; + Extensions = [.. source.Extensions]; + } + /// /// Gets or sets a value indicating whether the agent supports SSE. /// diff --git a/src/A2A/Models/AgentCard.cs b/src/A2A/Models/AgentCard.cs index 38079802..a34f6b43 100644 --- a/src/A2A/Models/AgentCard.cs +++ b/src/A2A/Models/AgentCard.cs @@ -13,6 +13,35 @@ namespace A2A; /// public sealed class AgentCard { + /// + /// Creates a new instance of the class. + /// + public AgentCard() + { + } + + // A constructor for cloning purposes. + internal AgentCard(AgentCard source) + { + Name = source.Name; + Description = source.Description; + Url = source.Url; + IconUrl = source.IconUrl; + Provider = source.Provider is null ? new AgentProvider() : new AgentProvider(source.Provider); + Version = source.Version; + ProtocolVersion = source.ProtocolVersion; + DocumentationUrl = source.DocumentationUrl; + Capabilities = source.Capabilities is null ? new AgentCapabilities() : new AgentCapabilities(source.Capabilities); + SecuritySchemes = source.SecuritySchemes is null ? null : new Dictionary(source.SecuritySchemes, source.SecuritySchemes.Comparer); + Security = source.Security; + DefaultInputModes = [.. source.DefaultInputModes]; + DefaultOutputModes = [.. source.DefaultOutputModes]; + Skills = [.. source.Skills]; + SupportsAuthenticatedExtendedCard = source.SupportsAuthenticatedExtendedCard; + AdditionalInterfaces = [.. source.AdditionalInterfaces]; + PreferredTransport = source.PreferredTransport.HasValue ? new AgentTransport(source.PreferredTransport.Value) : null; + } + /// /// Gets or sets the human readable name of the agent. /// diff --git a/src/A2A/Models/AgentProvider.cs b/src/A2A/Models/AgentProvider.cs index 1b7696d4..a3900025 100644 --- a/src/A2A/Models/AgentProvider.cs +++ b/src/A2A/Models/AgentProvider.cs @@ -7,6 +7,19 @@ namespace A2A; /// public sealed class AgentProvider { + /// + /// Creates a new instance of the class. + /// + public AgentProvider() + { + } + + internal AgentProvider(AgentProvider source) + { + Organization = source.Organization; + Url = source.Url; + } + /// /// Agent provider's organization name. /// diff --git a/src/A2A/Models/AgentTransport.cs b/src/A2A/Models/AgentTransport.cs index c009046a..e0fdb28a 100644 --- a/src/A2A/Models/AgentTransport.cs +++ b/src/A2A/Models/AgentTransport.cs @@ -31,6 +31,11 @@ public AgentTransport(string label) this.Label = label; } + internal AgentTransport(AgentTransport source) + { + Label = source.Label; + } + /// /// Determines whether two instances are equal. /// diff --git a/src/A2A/Server/AgentCardProvider.cs b/src/A2A/Server/AgentCardProvider.cs new file mode 100644 index 00000000..4fbca4e2 --- /dev/null +++ b/src/A2A/Server/AgentCardProvider.cs @@ -0,0 +1,100 @@ +namespace A2A +{ + /// + /// Provides an agent card for a specific agent. + /// /// + public class AgentCardProvider + { + private readonly AgentCard? _agentCard; + + /// + /// Creates a new instance of the AgentCardProvider. + /// + /// The valid agent card to initialize with. + /// Thrown when agentCard is null. + /// Thrown when any required property of agentCard is null or empty. + public AgentCardProvider(AgentCard agentCard) => _agentCard = Validate(agentCard); + + /// + /// Creates a new instance of the AgentCardProvider without an agent card. + /// + /// + /// This constructor is protected to allow derived classes to instantiate without an agent card. + /// For example, when the agent card is built dynamically based on the agent URL. + /// + protected AgentCardProvider() => _agentCard = null; + + /// + /// Returns agent capability information for a given agent URL. + /// + /// The URL of the agent. + /// Cancellation token to cancel the operation. + /// A task representing the asynchronous operation, with the agent card as the result. + public virtual Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken = default) + { + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (string.IsNullOrWhiteSpace(agentUrl)) + { + return Task.FromException(new ArgumentNullException(nameof(agentUrl))); + } + + if (_agentCard is null) + { + return Task.FromException(new InvalidOperationException("Derived types have to override this method.")); + } + + // Ensure the agent card is cloned to avoid modifying the original instance + // or even worse, the same instance being modified by another thread with different agent URL. + return Task.FromResult(new AgentCard(_agentCard) { Url = agentUrl }); + } + + /// + /// Validates the provided agent card to ensure it meets the required criteria. + /// + /// The agent card to validate. + /// The validated agent card. + /// Thrown when is null. + /// Thrown when any required property of is invalid. + public static AgentCard Validate(AgentCard agentCard) + { + if (agentCard is null) + { + throw new ArgumentNullException(nameof(agentCard)); + } + else if (string.IsNullOrWhiteSpace(agentCard.Name)) + { + throw new ArgumentException("Agent card must define a non-empty Name.", nameof(agentCard)); + } + else if (string.IsNullOrWhiteSpace(agentCard.Description)) + { + throw new ArgumentException("Agent card must define a non-empty Description.", nameof(agentCard)); + } + else if (string.IsNullOrWhiteSpace(agentCard.Version)) + { + throw new ArgumentException("Agent card must define a non-empty Version.", nameof(agentCard)); + } + else if (string.IsNullOrWhiteSpace(agentCard.ProtocolVersion)) + { + throw new ArgumentException("Agent card must define a non-empty ProtocolVersion.", nameof(agentCard)); + } + else if (agentCard.Capabilities is null) + { + throw new ArgumentException("Agent card must define Capabilities.", nameof(agentCard)); + } + else if (agentCard.DefaultInputModes is null || agentCard.DefaultInputModes.Count == 0) + { + throw new ArgumentException("Agent card must define DefaultInputModes with at least one mode.", nameof(agentCard)); + } + else if (agentCard.DefaultOutputModes is null || agentCard.DefaultOutputModes.Count == 0) + { + throw new ArgumentException("Agent card must define DefaultOutputModes with at least one mode.", nameof(agentCard)); + } + + return agentCard; + } + } +} diff --git a/src/A2A/Server/ITaskManager.cs b/src/A2A/Server/ITaskManager.cs index 0cf0910c..61cdeaaf 100644 --- a/src/A2A/Server/ITaskManager.cs +++ b/src/A2A/Server/ITaskManager.cs @@ -44,14 +44,6 @@ public interface ITaskManager /// Func OnTaskUpdated { get; set; } - /// - /// Gets or sets the handler for when an agent card is queried. - /// - /// - /// Returns agent capability information for a given agent URL. - /// - Func> OnAgentCardQuery { get; set; } - /// /// Creates a new agent task with a unique ID and initial status. /// diff --git a/src/A2A/Server/TaskManager.cs b/src/A2A/Server/TaskManager.cs index 47d2099c..04150ca4 100644 --- a/src/A2A/Server/TaskManager.cs +++ b/src/A2A/Server/TaskManager.cs @@ -35,12 +35,6 @@ public sealed class TaskManager : ITaskManager /// public Func OnTaskUpdated { get; set; } = static (_, _) => Task.CompletedTask; - /// - public Func> OnAgentCardQuery { get; set; } - = static (agentUrl, ct) => ct.IsCancellationRequested - ? Task.FromCanceled(ct) - : Task.FromResult(new AgentCard() { Name = "Unknown", Url = agentUrl }); - /// /// Initializes a new instance of the TaskManager class. /// diff --git a/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs index 82ebaf24..ef27e340 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -32,11 +32,12 @@ public void MapWellKnownAgentCard_RegistersEndpoint_WithCorrectPath() var services = serviceCollection.BuildServiceProvider(); var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); + AgentCard agentCard = CreateValidAgentCard(); + AgentCardProvider agentCardProvider = new(agentCard); // Act & Assert - Should not throw - var result = app.MapWellKnownAgentCard(taskManager, "/agent"); - Assert.NotNull(result); + Assert.NotNull(app.MapWellKnownAgentCard(agentCard, "/agentCard")); + Assert.NotNull(app.MapWellKnownAgentCard(agentCardProvider, "/agentCardProvider")); } [Fact] @@ -50,13 +51,17 @@ public void MapA2A_And_MapWellKnownAgentCard_Together_RegistersBothEndpoints() var app = WebApplication.CreateBuilder().Build(); var taskManager = new TaskManager(); + AgentCard agentCard = CreateValidAgentCard(); + AgentCardProvider agentCardProvider = new(agentCard); // Act & Assert - Should not throw when calling both var result1 = app.MapA2A(taskManager, "/agent"); - var result2 = app.MapWellKnownAgentCard(taskManager, "/agent"); + var result2 = app.MapWellKnownAgentCard(agentCard, "/agentCard"); + var result3 = app.MapWellKnownAgentCard(agentCardProvider, "/agentCardProvider"); Assert.NotNull(result1); Assert.NotNull(result2); + Assert.NotNull(result3); } [Theory] @@ -86,16 +91,19 @@ public void MapWellKnownAgentCard_ThrowsArgumentException_WhenAgentPathIsNullOrE { // Arrange var app = WebApplication.CreateBuilder().Build(); - var taskManager = new TaskManager(); + AgentCard agentCard = CreateValidAgentCard(); + AgentCardProvider agentCardProvider = new(agentCard); // Act & Assert if (agentPath == null) { - Assert.Throws(() => app.MapWellKnownAgentCard(taskManager, agentPath!)); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCard, agentPath!)); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCardProvider, agentPath!)); } else { - Assert.Throws(() => app.MapWellKnownAgentCard(taskManager, agentPath)); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCard, agentPath)); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCardProvider, agentPath)); } } @@ -116,6 +124,17 @@ public void MapWellKnownAgentCard_RequiresNonNullTaskManager() var app = WebApplication.CreateBuilder().Build(); // Act & Assert - Assert.Throws(() => app.MapWellKnownAgentCard(null!, "/agent")); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCard: null!, "/agent")); + Assert.Throws(() => app.MapWellKnownAgentCard(agentCardProvider: null!, "/agent")); } + + private static AgentCard CreateValidAgentCard() + => new AgentCard + { + Name = "Test Agent", + Description = "A test agent for unit testing.", + Version = "1.0.0", + DefaultInputModes = [ "text" ], + DefaultOutputModes = ["text"] + }; } \ No newline at end of file diff --git a/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs b/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs index 440da983..426ab3b4 100644 --- a/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs +++ b/tests/A2A.AspNetCore.UnitTests/A2AHttpProcessorTests.cs @@ -10,23 +10,6 @@ namespace A2A.AspNetCore.Tests; public class A2AHttpProcessorTests { - [Fact] - public async Task GetAgentCard_ShouldReturnValidJsonResult() - { - // Arrange - var taskManager = new TaskManager(); - var logger = NullLogger.Instance; - - // Act - var result = await A2AHttpProcessor.GetAgentCardAsync(taskManager, logger, "http://example.com", CancellationToken.None); - (int statusCode, string? contentType, AgentCard agentCard) = await GetAgentCardResponse(result); - - // Assert - Assert.Equal(StatusCodes.Status200OK, statusCode); - Assert.Equal("application/json; charset=utf-8", contentType); - Assert.Equal("Unknown", agentCard.Name); - } - [Fact] public async Task GetTask_ShouldReturnNotNull() { diff --git a/tests/A2A.UnitTests/Server/AgentCardProviderTests.cs b/tests/A2A.UnitTests/Server/AgentCardProviderTests.cs new file mode 100644 index 00000000..56010c54 --- /dev/null +++ b/tests/A2A.UnitTests/Server/AgentCardProviderTests.cs @@ -0,0 +1,162 @@ +namespace A2A.UnitTests.Server +{ + public class AgentCardProviderTests + { + [Fact] + public async Task UserCanCustomizeReturnedAgentCardByProvidingItUpFront() + { + AgentCard expected = CreateValidAgentCard(); + + AgentCardProvider provider = new(expected); + + AgentCard returned = await provider.GetAgentCardAsync("https://example.com/test-agent"); + + Assert.False(ReferenceEquals(expected, returned), "Returned agent card should be a copy of the provided card."); + Assert.Equal(expected.Name, returned.Name); + Assert.Equal(expected.Description, returned.Description); + Assert.Equal(expected.Version, returned.Version); + Assert.Equal(expected.Url, returned.Url); + Assert.Equal(expected.Capabilities.PushNotifications, returned.Capabilities.PushNotifications); + Assert.Equal(expected.Capabilities.Streaming, returned.Capabilities.Streaming); + } + + [Theory] + [InlineData(CustomAgentCardProvider.FirstAgentUrl, CustomAgentCardProvider.FirstAgentName)] + [InlineData(CustomAgentCardProvider.SecondAgentUrl, CustomAgentCardProvider.SecondAgentName)] + public async Task UserCanCustomizeReturnedAgentCardByBuildingItWhenRequested(string url, string expectedName) + { + CustomAgentCardProvider provider = new(); + + AgentCard agentCard = await provider.GetAgentCardAsync(url); + + Assert.Equal(expectedName, agentCard.Name); + Assert.Equal(url, agentCard.Url); + } + + private sealed class CustomAgentCardProvider : AgentCardProvider + { + internal const string FirstAgentUrl = "https://example.com/first-agent"; + internal const string SecondAgentUrl = "https://example.com/second-agent"; + internal const string FirstAgentName = "First"; + internal const string SecondAgentName = "Second"; + + public override Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken = default) + { + return Task.FromResult(new AgentCard() + { + Name = agentUrl == FirstAgentUrl ? FirstAgentName : SecondAgentName, + Description = "Custom Agent Card Provider", + Version = "1.0.0", + Url = agentUrl, + Capabilities = new AgentCapabilities + { + PushNotifications = true, + Streaming = true + } + }); + } + } + + [Fact] + public async Task InvalidAgentCardProviderThrowsException() + { + InvalidAgentCardProvider provider = new(); + + await Assert.ThrowsAsync( + () => provider.GetAgentCardAsync("https://example.com/invalid-agent")); + } + + private sealed class InvalidAgentCardProvider : AgentCardProvider + { + // This type does not specify an agent card via constructor + // and does not override GetAgentCardAsync, so it will throw an exception. + } + + [Fact] + public void NullIsInvalidAgentCard() + { + Assert.Throws(() => new AgentCardProvider(agentCard: null!)); + Assert.Throws(() => AgentCardProvider.Validate(agentCard: null!)); + } + + public static IEnumerable InvalidStrings() + { + yield return new object[] { null! }; + yield return new object[] { "" }; + yield return new object[] { " " }; + } + + [Theory, MemberData(nameof(InvalidStrings))] + public void AgentCardMustDefineNonEmptyName(string? invalidName) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.Name = invalidName!; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define a non-empty Name. (Parameter 'agentCard')", ex.Message); + } + + [Theory, MemberData(nameof(InvalidStrings))] + public void AgentCardMustDefineNonEmptyDescription(string? invalidDescription) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.Description = invalidDescription!; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define a non-empty Description. (Parameter 'agentCard')", ex.Message); + } + + [Theory, MemberData(nameof(InvalidStrings))] + public void AgentCardMustDefineNonEmptyVersion(string? invalidVersion) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.Version = invalidVersion!; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define a non-empty Version. (Parameter 'agentCard')", ex.Message); + } + + [Theory, MemberData(nameof(InvalidStrings))] + public void AgentCardMustDefineNonEmptyProtocolVersion(string? invalidVersion) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.ProtocolVersion = invalidVersion!; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define a non-empty ProtocolVersion. (Parameter 'agentCard')", ex.Message); + } + + public static IEnumerable InvalidModes() + { + yield return new object[] { null! }; + yield return new object[] { new List() }; + } + + [Theory, MemberData(nameof(InvalidModes))] + public void AgentCardMustDefineDefaultInputModesWithAtLeastOneMode(List modes) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.DefaultInputModes = modes; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define DefaultInputModes with at least one mode. (Parameter 'agentCard')", ex.Message); + } + + [Theory, MemberData(nameof(InvalidModes))] + public void AgentCardMustDefineDefaultOutputModesWithAtLeastOneMode(List modes) + { + AgentCard agentCard = CreateValidAgentCard(); + agentCard.DefaultOutputModes = modes; + var ex = Assert.Throws(() => AgentCardProvider.Validate(agentCard)); + Assert.Equal("Agent card must define DefaultOutputModes with at least one mode. (Parameter 'agentCard')", ex.Message); + } + + private static AgentCard CreateValidAgentCard() => new() + { + Name = "Test Agent", + Description = "This is a test agent expected.", + Version = "1.0.0", + Url = "https://example.com/test-agent", + Capabilities = new() + { + PushNotifications = false, + Streaming = true, + } + }; + } +} \ No newline at end of file