diff --git a/Directory.Packages.props b/Directory.Packages.props index beb4778e..1f58e358 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,13 +2,13 @@ true true - 2.0.0 + 2.2.0 1.0.8 - - + + @@ -28,9 +28,9 @@ - - - + + + diff --git a/benchmarks/CrestApps.Core.Benchmarks/A2AToolRegistryProviderBenchmarks.cs b/benchmarks/CrestApps.Core.Benchmarks/A2AToolRegistryProviderBenchmarks.cs index d8165f4e..aeefc68b 100644 --- a/benchmarks/CrestApps.Core.Benchmarks/A2AToolRegistryProviderBenchmarks.cs +++ b/benchmarks/CrestApps.Core.Benchmarks/A2AToolRegistryProviderBenchmarks.cs @@ -79,7 +79,13 @@ public void Setup() { Name = $"Agent {connectionIndex}", Description = $"Benchmark agent {connectionIndex}.", - Url = $"https://agent-{connectionIndex}.example", + SupportedInterfaces = + [ + new AgentInterface + { + Url = $"https://agent-{connectionIndex}.example", + }, + ], Version = "1.0", Skills = skills, })); diff --git a/benchmarks/CrestApps.Core.Benchmarks/CrestApps.Core.Benchmarks.csproj b/benchmarks/CrestApps.Core.Benchmarks/CrestApps.Core.Benchmarks.csproj index b8f6bd62..3f88e2a5 100644 --- a/benchmarks/CrestApps.Core.Benchmarks/CrestApps.Core.Benchmarks.csproj +++ b/benchmarks/CrestApps.Core.Benchmarks/CrestApps.Core.Benchmarks.csproj @@ -3,6 +3,7 @@ Exe false + $(NoWarn);CA1001;CA1305;CA1848 diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.2.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.2.0.md index 0a8acc78..72c9c709 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.2.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.2.0.md @@ -13,6 +13,11 @@ description: Release notes for the CrestApps.Core 1.2.0 release. ## Change Logs +- Updated the A2A client, proxy tools, MVC sample host, and Blazor sample host to use the A2A 1.0 preview protocol models and ASP.NET Core endpoint APIs. +- Updated A2A tests and benchmark build settings for the A2A 1.0 preview API and stricter analyzer defaults. +- Fixed the A2A client sample error handling so authentication redirects and non-JSON host responses produce actionable messages instead of JSON parser errors. +- Corrected the A2A client sample's standalone MVC and Blazor host endpoints to match each project's HTTPS launch URL and improved local TLS/connection error messages. +- Updated the Aspire sample host to set the effective A2A host options for local unauthenticated A2A sample-client calls. - Downgraded YesSql to 5.4.7 to unblock the release of CrestApps.OrchardCore 2.1. - upgrades SSH.NET to 2026.0.0 to address the high-severity [GHSA-q939-rpr3-3284](https://github.com/advisories/GHSA-q939-rpr3-3284) diff --git a/src/Primitives/CrestApps.Core.AI.A2A/A2ALog.cs b/src/Primitives/CrestApps.Core.AI.A2A/A2ALog.cs new file mode 100644 index 00000000..90570ed1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.A2A/A2ALog.cs @@ -0,0 +1,105 @@ +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.A2A; + +internal static class A2ALog +{ + private static readonly Action _failedToLoadAgentCardForConnection = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(FailedToLoadAgentCardForConnection)), + "Failed to load agent card for A2A connection '{ConnectionId}'."); + + private static readonly Action _failedToFetchAgentCardFromHost = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1002, nameof(FailedToFetchAgentCardFromHost)), + "Failed to fetch agent card from A2A host '{Endpoint}' for connection '{ConnectionId}'."); + + private static readonly Action _failedToCommunicateWithRemoteAgent = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1003, nameof(FailedToCommunicateWithRemoteAgent)), + "Failed to communicate with remote A2A agent '{AgentName}' at '{Endpoint}'."); + + private static readonly Action _failedToListLocalAgentProfiles = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1004, nameof(FailedToListLocalAgentProfiles)), + "Failed to list local agent profiles."); + + private static readonly Action _failedToFetchAgentCardFromConnection = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1005, nameof(FailedToFetchAgentCardFromConnection)), + "Failed to fetch agent card from A2A connection '{ConnectionId}'."); + + private static readonly Action _failedToListRemoteA2AAgents = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1006, nameof(FailedToListRemoteA2AAgents)), + "Failed to list remote A2A agents."); + + private static readonly Action _failedToSearchForTools = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1007, nameof(FailedToSearchForTools)), + "Failed to search for tools."); + + private static readonly Action _failedToSearchLocalAgentProfiles = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1008, nameof(FailedToSearchLocalAgentProfiles)), + "Failed to search local agent profiles."); + + private static readonly Action _failedToSearchRemoteA2AAgents = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1009, nameof(FailedToSearchRemoteA2AAgents)), + "Failed to search remote A2A agents."); + + public static void FailedToLoadAgentCardForConnection(ILogger logger, string connectionId, Exception exception) + { + _failedToLoadAgentCardForConnection(logger, connectionId, exception); + } + + public static void FailedToFetchAgentCardFromHost(ILogger logger, string endpoint, string connectionId, Exception exception) + { + _failedToFetchAgentCardFromHost(logger, endpoint, connectionId, exception); + } + + public static void FailedToCommunicateWithRemoteAgent(ILogger logger, string agentName, string endpoint, Exception exception) + { + _failedToCommunicateWithRemoteAgent(logger, agentName, endpoint, exception); + } + + public static void FailedToListLocalAgentProfiles(ILogger logger, Exception exception) + { + _failedToListLocalAgentProfiles(logger, exception); + } + + public static void FailedToFetchAgentCardFromConnection(ILogger logger, string connectionId, Exception exception) + { + _failedToFetchAgentCardFromConnection(logger, connectionId, exception); + } + + public static void FailedToListRemoteA2AAgents(ILogger logger, Exception exception) + { + _failedToListRemoteA2AAgents(logger, exception); + } + + public static void FailedToSearchForTools(ILogger logger, Exception exception) + { + _failedToSearchForTools(logger, exception); + } + + public static void FailedToSearchLocalAgentProfiles(ILogger logger, Exception exception) + { + _failedToSearchLocalAgentProfiles(logger, exception); + } + + public static void FailedToSearchRemoteA2AAgents(ILogger logger, Exception exception) + { + _failedToSearchRemoteA2AAgents(logger, exception); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs index 583ca14b..02cd6c18 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindAgentForTaskFunction.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.A2A.Services; using CrestApps.Core.AI.Extensions; @@ -106,7 +107,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } catch (Exception ex) { - logger.LogWarning(ex, "Failed to search local agent profiles."); + A2ALog.FailedToSearchLocalAgentProfiles(logger, ex); } try @@ -143,13 +144,13 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } catch (Exception ex) { - logger.LogWarning(ex, "Failed to fetch agent card from A2A connection '{ConnectionId}'.", connection.ItemId); + A2ALog.FailedToFetchAgentCardFromConnection(logger, connection.ItemId, ex); } } } catch (Exception ex) { - logger.LogWarning(ex, "Failed to search remote A2A agents."); + A2ALog.FailedToSearchRemoteA2AAgents(logger, ex); } var results = scoredAgents.Where(s => s.Score > 0).OrderByDescending(s => s.Score).Take(maxResults).Select(s => s.Agent).ToList(); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs index b3b9a32c..c9bc8363 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/FindToolsForTaskFunction.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Extensions; using CrestApps.Core.AI.Models; @@ -111,7 +112,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } catch (Exception ex) { - logger.LogWarning(ex, "Failed to search for tools."); + A2ALog.FailedToSearchForTools(logger, ex); return "An error occurred while searching for tools."; } diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs b/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs index 36abe1e5..84dee1ca 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Functions/ListAvailableAgentsFunction.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.A2A.Services; using CrestApps.Core.AI.Models; @@ -66,7 +67,7 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } catch (Exception ex) { - logger.LogWarning(ex, "Failed to list local agent profiles."); + A2ALog.FailedToListLocalAgentProfiles(logger, ex); } try @@ -96,13 +97,13 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a } catch (Exception ex) { - logger.LogWarning(ex, "Failed to fetch agent card from A2A connection '{ConnectionId}'.", connection.ItemId); + A2ALog.FailedToFetchAgentCardFromConnection(logger, connection.ItemId, ex); } } } catch (Exception ex) { - logger.LogWarning(ex, "Failed to list remote A2A agents."); + A2ALog.FailedToListRemoteA2AAgents(logger, ex); } return agents.Count > 0 ? JsonSerializer.Serialize(agents) : "No agents are currently available."; diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs index 07f2a9c1..d3abd0ed 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs @@ -1,5 +1,6 @@ using System.Text.Json; using A2A; +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.Services; using Microsoft.Extensions.AI; @@ -111,24 +112,24 @@ protected override async ValueTask InvokeCoreAsync( var client = new A2AClient(new Uri(_endpoint), httpClient); - var agentMessage = new AgentMessage + var agentMessage = new Message { - Role = MessageRole.User, + Role = Role.User, MessageId = Guid.NewGuid().ToString(), ContextId = contextId ?? Guid.NewGuid().ToString(), - Parts = [new TextPart { Text = message }], + Parts = [Part.FromText(message)], Metadata = new Dictionary { ["agentName"] = JsonSerializer.SerializeToElement(_agentName), }, }; - var sendParams = new MessageSendParams + var sendRequest = new SendMessageRequest { Message = agentMessage, }; - var response = await client.SendMessageAsync(sendParams, cancellationToken); + var response = await client.SendMessageAsync(sendRequest, cancellationToken); var responseText = ExtractTextFromResponse(response); @@ -140,7 +141,7 @@ protected override async ValueTask InvokeCoreAsync( } catch (Exception ex) { - logger.LogError(ex, "Failed to communicate with remote A2A agent '{AgentName}' at '{Endpoint}'.", _agentName, _endpoint); + A2ALog.FailedToCommunicateWithRemoteAgent(logger, _agentName, _endpoint, ex); return $"An error occurred while communicating with remote agent '{_agentName}'."; } @@ -185,24 +186,25 @@ private static bool TryGetString( /// /// The A2A response. /// The response text, or when no usable text exists. - internal static string ExtractTextFromResponse(A2AResponse response) + internal static string ExtractTextFromResponse(SendMessageResponse response) { - if (response is AgentMessage message) + if (response.Message is { } message) { - var texts = message.Parts?.OfType().Select(p => p.Text); + var texts = message.Parts.Select(p => p.Text).OfType(); - if (texts?.Any() == true) + if (texts.Any()) { return string.Join(string.Empty, texts); } } - else if (response is AgentTask task) + else if (response.Task is { } task) { if (task.Artifacts?.Count > 0) { var artifactTexts = task.Artifacts - .SelectMany(a => a.Parts?.OfType() ?? []) - .Select(p => p.Text); + .SelectMany(a => a.Parts ?? []) + .Select(p => p.Text) + .OfType(); var combined = string.Join(string.Empty, artifactTexts); @@ -214,7 +216,9 @@ internal static string ExtractTextFromResponse(A2AResponse response) if (task.Status.Message?.Parts is not null) { - var statusTexts = task.Status.Message.Parts.OfType().Select(p => p.Text); + var statusTexts = task.Status.Message.Parts + .Select(p => p.Text) + .OfType(); var combined = string.Join(string.Empty, statusTexts); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs index 4ece448c..a58ccf86 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AToolRegistryProvider.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Tooling; @@ -89,7 +90,7 @@ public async Task> GetToolsAsync( } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to load agent card for A2A connection '{ConnectionId}'.", connectionId); + A2ALog.FailedToLoadAgentCardForConnection(_logger, connectionId, ex); } } diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs index 0b8a7c0d..8038f98c 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs @@ -1,4 +1,5 @@ using A2A; +using CrestApps.Core.AI.A2A; using CrestApps.Core.AI.A2A.Models; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; @@ -75,7 +76,7 @@ public async Task GetAgentCardAsync(string connectionId, A2AConnectio } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to fetch agent card from A2A host '{Endpoint}' for connection '{ConnectionId}'.", connection.Endpoint, connectionId); + A2ALog.FailedToFetchAgentCardFromHost(_logger, connection.Endpoint, connectionId, ex); return null; } diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs index 8c8f6523..4a8aff60 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs @@ -80,8 +80,8 @@ void WriteCrashEntry(string label, object data) options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__MvcApp__MCP__Server__AuthenticationType", "None"); - options.EnvironmentVariables.Add("CrestApps__MvcApp__A2A__Host__AuthenticationType", "None"); - options.EnvironmentVariables.Add("CrestApps__MvcApp__A2A__Host__ExposeAgentsAsSkill", "true"); + options.EnvironmentVariables.Add("A2AHostOptions__AuthenticationType", "None"); + options.EnvironmentVariables.Add("A2AHostOptions__ExposeAgentsAsSkill", "true"); options.EnvironmentVariables.Add("CrestApps__PostgreSQL__ConnectionString", postgres.Resource.ConnectionStringExpression); // Prevent VS-injected startup hooks (BrowserRefresh, DeltaApplier, BrowserLink) @@ -102,8 +102,8 @@ void WriteCrashEntry(string label, object data) options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__BlazorApp__MCP__Server__AuthenticationType", "None"); - options.EnvironmentVariables.Add("CrestApps__BlazorApp__A2A__Host__AuthenticationType", "None"); - options.EnvironmentVariables.Add("CrestApps__BlazorApp__A2A__Host__ExposeAgentsAsSkill", "true"); + options.EnvironmentVariables.Add("A2AHostOptions__AuthenticationType", "None"); + options.EnvironmentVariables.Add("A2AHostOptions__ExposeAgentsAsSkill", "true"); options.EnvironmentVariables.Add("CrestApps__PostgreSQL__ConnectionString", postgres.Resource.ConnectionStringExpression); // Prevent VS-injected startup hooks (BrowserRefresh, DeltaApplier, BrowserLink) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs index a3fc97eb..f98504db 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileDocumentService.cs @@ -16,6 +16,24 @@ namespace CrestApps.Core.Blazor.Web.Areas.AI.Services; /// public sealed class AIProfileDocumentService { + private static readonly Action _failedToProcessFile = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(FailedToProcessFile)), + "Failed to process file '{FileName}': {Error}"); + + private static readonly Action _errorProcessingUploadedFile = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1002, nameof(ErrorProcessingUploadedFile)), + "Error processing uploaded file '{FileName}'."); + + private static readonly Action _errorRemovingDocument = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1003, nameof(ErrorRemovingDocument)), + "Error removing document '{DocumentId}'."); + private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -72,7 +90,7 @@ public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection ResolveProfileDeploymentAsync(AIProfile return null; } + + private static void FailedToProcessFile(ILogger logger, string fileName, string error) + { + _failedToProcessFile(logger, fileName, error, null); + } + + private static void ErrorProcessingUploadedFile(ILogger logger, string fileName, Exception exception) + { + _errorProcessingUploadedFile(logger, fileName, exception); + } + + private static void ErrorRemovingDocument(ILogger logger, string documentId, Exception exception) + { + _errorRemovingDocument(logger, documentId, exception); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs index 03c36be1..21b110f8 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs @@ -11,6 +11,30 @@ namespace CrestApps.Core.Blazor.Web.Areas.AI.Services; public sealed class AIProfileTemplateDocumentService { + private static readonly Action _failedToProcessFile = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(FailedToProcessFile)), + "Failed to process file '{FileName}': {Error}"); + + private static readonly Action _errorProcessingUploadedFile = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1002, nameof(ErrorProcessingUploadedFile)), + "Error processing uploaded file '{FileName}'."); + + private static readonly Action _errorRemovingTemplateDocument = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1003, nameof(ErrorRemovingTemplateDocument)), + "Error removing template document '{DocumentId}'."); + + private static readonly Action _templateStoredFileNotFound = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1004, nameof(TemplateStoredFileNotFound)), + "Template document '{DocumentId}' referenced stored file '{StoredFilePath}', but the file was not found."); + private readonly IAIDocumentStore _documentStore; private readonly IAIDocumentChunkStore _chunkStore; private readonly IDocumentFileStore _fileStore; @@ -66,7 +90,7 @@ public async Task UploadDocumentsAsync(AIProfileTemplate template, IReadOnlyColl if (!result.Success) { - _logger.LogWarning("Failed to process file '{FileName}': {Error}", file.FileName, result.Error); + FailedToProcessFile(_logger, file.FileName, result.Error); continue; } @@ -99,7 +123,7 @@ public async Task UploadDocumentsAsync(AIProfileTemplate template, IReadOnlyColl } catch (Exception ex) { - _logger.LogError(ex, "Error processing uploaded file '{FileName}'.", file.FileName); + ErrorProcessingUploadedFile(_logger, file.FileName, ex); } } } @@ -160,7 +184,7 @@ public async Task RemoveDocumentsAsync(AIProfileTemplate template, IReadOnlyColl } catch (Exception ex) { - _logger.LogError(ex, "Error removing template document '{DocumentId}'.", documentId); + ErrorRemovingTemplateDocument(_logger, documentId, ex); } } @@ -220,10 +244,7 @@ public async Task CloneDocumentsToProfileAsync(AIProfileTemplate template, AIPro } else { - _logger.LogWarning( - "Template document '{DocumentId}' referenced stored file '{StoredFilePath}', but the file was not found.", - templateDocument.ItemId, - templateDocument.StoredFilePath); + TemplateStoredFileNotFound(_logger, templateDocument.ItemId, templateDocument.StoredFilePath); } } @@ -343,4 +364,24 @@ private async Task ResolveTemplateDeploymentAsync(AIProfileTemplat return null; } + + private static void FailedToProcessFile(ILogger logger, string fileName, string error) + { + _failedToProcessFile(logger, fileName, error, null); + } + + private static void ErrorProcessingUploadedFile(ILogger logger, string fileName, Exception exception) + { + _errorProcessingUploadedFile(logger, fileName, exception); + } + + private static void ErrorRemovingTemplateDocument(ILogger logger, string documentId, Exception exception) + { + _errorRemovingTemplateDocument(logger, documentId, exception); + } + + private static void TemplateStoredFileNotFound(ILogger logger, string documentId, string storedFilePath) + { + _templateStoredFileNotFound(logger, documentId, storedFilePath, null); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs index 68d71611..8e71aa07 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleClaudeOptionsConfiguration.cs @@ -9,6 +9,12 @@ internal sealed class SampleClaudeOptionsConfiguration : IConfigureOptions _failedToUnprotectApiKey = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(FailedToUnprotectApiKey)), + "Failed to unprotect Anthropic API key."); + private readonly SiteSettingsStore _siteSettings; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly ILogger _logger; @@ -70,7 +76,12 @@ public static void Apply( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); + FailedToUnprotectApiKey(logger, ex); } } + + private static void FailedToUnprotectApiKey(ILogger logger, Exception exception) + { + _failedToUnprotectApiKey(logger, exception); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs index 183a393e..ba8b4110 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Areas/AIChat/Services/SampleCopilotOptionsConfiguration.cs @@ -9,6 +9,18 @@ internal sealed class SampleCopilotOptionsConfiguration : IConfigureOptions _failedToUnprotectClientSecret = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(FailedToUnprotectClientSecret)), + "Failed to unprotect Copilot client secret."); + + private static readonly Action _failedToUnprotectApiKey = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1002, nameof(FailedToUnprotectApiKey)), + "Failed to unprotect Copilot API key."); + private readonly SiteSettingsStore _siteSettings; private readonly IDataProtectionProvider _dataProtectionProvider; private readonly ILogger _logger; @@ -69,7 +81,7 @@ public static void Apply( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to unprotect Copilot client secret."); + FailedToUnprotectClientSecret(logger, ex); } } @@ -81,8 +93,18 @@ public static void Apply( } catch (Exception ex) { - logger.LogWarning(ex, "Failed to unprotect Copilot API key."); + FailedToUnprotectApiKey(logger, ex); } } } + + private static void FailedToUnprotectClientSecret(ILogger logger, Exception exception) + { + _failedToUnprotectClientSecret(logger, exception); + } + + private static void FailedToUnprotectApiKey(ILogger logger, Exception exception) + { + _failedToUnprotectApiKey(logger, exception); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor index e44645c1..70499fce 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Create.razor @@ -1323,7 +1323,7 @@ if (_pendingFiles.Count > 0) { - using var formFiles = await BrowserFileFormFileCollection.CreateAsync(_pendingFiles); + using var formFiles = await BrowserFileFormFiles.CreateAsync(_pendingFiles); if (formFiles.Files.Count > 0) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor index be183d56..e67af233 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/AI/AIProfiles/Edit.razor @@ -1214,7 +1214,7 @@ else if (_model != null) if (_pendingFiles.Count > 0) { - using var formFiles = await BrowserFileFormFileCollection.CreateAsync(_pendingFiles); + using var formFiles = await BrowserFileFormFiles.CreateAsync(_pendingFiles); if (formFiles.Files.Count > 0) { diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs index 0de0fd07..24bf7944 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Program.cs @@ -1,6 +1,7 @@ using CrestApps.Core; using CrestApps.Core.AI; using CrestApps.Core.AI.A2A; +using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Azure.AISearch; using CrestApps.Core.AI.AzureAIInference; using CrestApps.Core.AI.Chat; @@ -83,6 +84,32 @@ { options.LoginPath = "/account/login"; options.AccessDeniedPath = "/account/access-denied"; + options.Events.OnRedirectToLogin = context => + { + if (IsA2AProtocolRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if (IsA2AProtocolRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + + return Task.CompletedTask; + }; }); builder.Services.AddAuthorizationBuilder() .AddPolicy("Admin", policy => policy.RequireRole("Administrator")); @@ -155,6 +182,9 @@ ) ); +builder.Services.Configure( + builder.Configuration.GetSection(nameof(A2AHostOptions))); + // ============================================================================= // 4. MCP AND CUSTOM TOOLS // ============================================================================= @@ -272,3 +302,8 @@ .AddInteractiveServerRenderMode(); await app.RunAsync(); + +static bool IsA2AProtocolRequest(HttpRequest request) +{ + return request.Path.StartsWithSegments("/a2a"); +} diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs index ae4b4195..1e1cbce0 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Services/A2AHostExtensions.cs @@ -26,6 +26,12 @@ internal static class A2AHostExtensions DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + private static readonly Action _failedToExecuteAgent = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1001, nameof(FailedToExecuteAgent)), + "Failed to execute agent '{AgentName}'."); + public static IServiceCollection AddA2AHost(this IServiceCollection services) { services.AddScoped(); @@ -42,7 +48,15 @@ public static IServiceCollection AddA2AHost(this IServiceCollection services) policy.AddRequirements(new A2AHostAuthorizationRequirement()); }); - services.AddSingleton(CreateTaskManager); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(services => + new A2AServer( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService>())); return services; } @@ -58,47 +72,13 @@ public static IEndpointRouteBuilder MapA2AHost(this IEndpointRouteBuilder endpoi { endpoints.MapGet("/.well-known/agent-card.json", HandleWellKnownEndpointAsync); - var taskManager = endpoints.ServiceProvider.GetRequiredService(); - endpoints.MapA2A(taskManager, "a2a") + var requestHandler = endpoints.ServiceProvider.GetRequiredService(); + endpoints.MapA2A(requestHandler, "a2a") .RequireAuthorization(A2AHostPolicyName); return endpoints; } - private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) - { - var httpContextAccessor = serviceProvider.GetRequiredService(); - var taskManager = new TaskManager(); - - taskManager.OnAgentCardQuery = async (agentUrl, cancellationToken) => - { - var services = httpContextAccessor.HttpContext!.RequestServices; - var options = services.GetRequiredService>().CurrentValue; - var profileManager = services.GetRequiredService(); - var profiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); - - if (options.ExposeAgentsAsSkill) - { - return BuildSkillModeCard(agentUrl, profiles); - } - - var agentName = httpContextAccessor.HttpContext?.Request.Query["agent"].FirstOrDefault(); - var targetProfile = ResolveAgentProfile(profiles, agentName); - - return targetProfile is not null - ? BuildAgentCard(targetProfile, agentUrl) - : BuildSkillModeCard(agentUrl, profiles); - }; - - taskManager.OnTaskCreated = (agentTask, cancellationToken) => - ProcessAgentTaskAsync(taskManager, httpContextAccessor, agentTask, cancellationToken); - - taskManager.OnTaskUpdated = (agentTask, cancellationToken) => - ProcessAgentTaskAsync(taskManager, httpContextAccessor, agentTask, cancellationToken); - - return taskManager; - } - private static async Task HandleWellKnownEndpointAsync(HttpContext context) { var options = context.RequestServices.GetRequiredService>().CurrentValue; @@ -133,53 +113,44 @@ private static async Task HandleWellKnownEndpointAsync(HttpContext context) } } - private static async Task ProcessAgentTaskAsync( - TaskManager taskManager, + private static async Task ProcessAgentRequestAsync( + TaskUpdater updater, IHttpContextAccessor httpContextAccessor, - AgentTask agentTask, + RequestContext requestContext, + AgentEventQueue eventQueue, CancellationToken cancellationToken) { var services = httpContextAccessor.HttpContext?.RequestServices; if (services is null) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "Request services are not available."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "Request services are not available."), cancellationToken: cancellationToken); return; } - var logger = services.GetRequiredService>(); + var logger = services.GetRequiredService>(); - var lastMessage = agentTask.History?.LastOrDefault(); - var prompt = lastMessage?.Parts?.OfType().FirstOrDefault()?.Text; + var prompt = requestContext.UserText; if (string.IsNullOrWhiteSpace(prompt)) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "No text message was provided."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "No text message was provided."), cancellationToken: cancellationToken); return; } var targetProfile = await ResolveTargetProfileAsync( - services, httpContextAccessor, lastMessage); + services, httpContextAccessor, requestContext.Message); if (targetProfile is null) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "No agents are available to process this request."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "No agents are available to process this request."), cancellationToken: cancellationToken); return; @@ -187,10 +158,12 @@ await taskManager.UpdateStatusAsync( try { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Working, - cancellationToken: cancellationToken); + if (!requestContext.IsContinuation) + { + await updater.SubmitAsync(cancellationToken: cancellationToken); + } + + await updater.StartWorkAsync(cancellationToken: cancellationToken); var completionService = services.GetRequiredService(); var contextBuilder = services.GetRequiredService(); @@ -208,6 +181,8 @@ await taskManager.UpdateStatusAsync( }; var responseText = new System.Text.StringBuilder(); + var artifactId = Guid.NewGuid().ToString("N"); + var appendArtifact = false; await foreach (var update in completionService.CompleteStreamingAsync( deployment, messages, context, cancellationToken)) @@ -218,13 +193,14 @@ await taskManager.UpdateStatusAsync( { responseText.Append(chunk); - await taskManager.ReturnArtifactAsync( - agentTask.Id, - new Artifact - { - Parts = [new TextPart { Text = chunk }], - }, - cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText(chunk)], + artifactId: artifactId, + append: appendArtifact, + lastChunk: false, + cancellationToken: cancellationToken); + + appendArtifact = true; } } @@ -232,30 +208,38 @@ await taskManager.ReturnArtifactAsync( ? responseText.ToString() : "The agent did not produce a response."; - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Completed, - CreateAgentMessage(agentTask.ContextId, finalText), - final: true, + if (appendArtifact) + { + await eventQueue.EnqueueArtifactUpdateAsync( + new TaskArtifactUpdateEvent + { + TaskId = updater.TaskId, + ContextId = updater.ContextId, + Artifact = new Artifact + { + ArtifactId = artifactId, + Parts = [], + }, + Append = true, + LastChunk = true, + }, + cancellationToken); + } + + await updater.CompleteAsync( + CreateAgentMessage(requestContext.ContextId, finalText), cancellationToken: cancellationToken); } catch (OperationCanceledException) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Canceled, - final: true, - cancellationToken: CancellationToken.None); + await updater.CancelAsync(cancellationToken: CancellationToken.None); } catch (Exception ex) { - logger.LogError(ex, "Failed to execute agent '{AgentName}'.", targetProfile.Name); + FailedToExecuteAgent(logger, targetProfile.Name, ex); - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, $"An error occurred while executing agent '{targetProfile.Name}'."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, $"An error occurred while executing agent '{targetProfile.Name}'."), cancellationToken: CancellationToken.None); } } @@ -263,7 +247,7 @@ await taskManager.UpdateStatusAsync( private static async Task ResolveTargetProfileAsync( IServiceProvider services, IHttpContextAccessor httpContextAccessor, - AgentMessage lastMessage) + Message message) { var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); @@ -283,7 +267,7 @@ private static async Task ResolveTargetProfileAsync( } if (targetProfile is null && - lastMessage?.Metadata?.TryGetValue("agentName", out var agentNameElement) == true) + message?.Metadata?.TryGetValue("agentName", out var agentNameElement) == true) { var metaAgentName = agentNameElement.GetString(); @@ -319,10 +303,18 @@ private static AgentCard BuildSkillModeCard(string agentUrl, IEnumerable { - ["apiKey"] = new ApiKeySecurityScheme( - name: "Authorization", - keyLocation: "header", - description: "API key authentication. Send as 'Bearer {key}' or 'ApiKey {key}' in the Authorization header."), + ["apiKey"] = new SecurityScheme + { + ApiKeySecurityScheme = new ApiKeySecurityScheme + { + Name = "Authorization", + Location = "header", + Description = "API key authentication. Send as 'Bearer {key}' or 'ApiKey {key}' in the Authorization header.", + }, + }, }; - card.Security = + card.SecurityRequirements = [ - new Dictionary { ["apiKey"] = [] }, + new SecurityRequirement + { + Schemes = new Dictionary + { + ["apiKey"] = new(), + }, + }, ]; break; case A2AHostAuthenticationType.OpenId: card.SecuritySchemes = new Dictionary { - ["openId"] = new OpenIdConnectSecurityScheme( - openIdConnectUrl: new Uri($"{baseUrl}/.well-known/openid-configuration"), - description: "OpenID Connect authentication."), + ["openId"] = new SecurityScheme + { + OpenIdConnectSecurityScheme = new OpenIdConnectSecurityScheme + { + OpenIdConnectUrl = $"{baseUrl}/.well-known/openid-configuration", + Description = "OpenID Connect authentication.", + }, + }, }; - card.Security = + card.SecurityRequirements = [ - new Dictionary { ["openId"] = [] }, + new SecurityRequirement + { + Schemes = new Dictionary + { + ["openId"] = new(), + }, + }, ]; break; } @@ -394,14 +426,55 @@ private static AIProfile ResolveAgentProfile(IEnumerable profiles, st string.Equals(p.Name, agentName, StringComparison.OrdinalIgnoreCase)); } - private static AgentMessage CreateAgentMessage(string contextId, string text) + private static Message CreateAgentMessage(string contextId, string text) { - return new AgentMessage + return new Message { - Role = MessageRole.Agent, + Role = Role.Agent, MessageId = Guid.NewGuid().ToString(), ContextId = contextId, - Parts = [new TextPart { Text = text }], + Parts = [Part.FromText(text)], }; } + + private static void FailedToExecuteAgent(ILogger logger, string agentName, Exception exception) + { + _failedToExecuteAgent(logger, agentName, exception); + } + + private sealed class BlazorA2AAgentHandler : IAgentHandler + { + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + /// Initializes a new instance of the class. + /// + /// The current HTTP context accessor. + public BlazorA2AAgentHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + /// + /// Executes the A2A agent request. + /// + /// The A2A request context. + /// The A2A event queue. + /// The cancellation token. + /// A task that represents the asynchronous operation. + public Task ExecuteAsync( + RequestContext context, + AgentEventQueue eventQueue, + CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + + return ProcessAgentRequestAsync( + updater, + _httpContextAccessor, + context, + eventQueue, + cancellationToken); + } + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFileCollection.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFiles.cs similarity index 53% rename from src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFileCollection.cs rename to src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFiles.cs index 2d7eea54..324c6b8d 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFileCollection.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Services/BrowserFileFormFiles.cs @@ -2,11 +2,19 @@ namespace CrestApps.Core.Blazor.Web.Services; -public sealed class BrowserFileFormFileCollection : IDisposable +/// +/// Provides form-file wrappers for browser files and owns their backing streams. +/// +public sealed class BrowserFileFormFiles : IDisposable { private readonly List _streams = []; - private BrowserFileFormFileCollection( + /// + /// Initializes a new instance of the class. + /// + /// The form files backed by browser-file streams. + /// The streams owned by the wrapper. + private BrowserFileFormFiles( List files, List streams) { @@ -14,9 +22,18 @@ private BrowserFileFormFileCollection( _streams = streams; } + /// + /// Gets the form files created from browser files. + /// public IReadOnlyList Files { get; } - public static async Task CreateAsync(IEnumerable files, CancellationToken cancellationToken = default) + /// + /// Creates form-file wrappers from browser files. + /// + /// The browser files to wrap. + /// The cancellation token. + /// The form-file wrapper with owned streams. + public static async Task CreateAsync(IEnumerable files, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(files); @@ -47,9 +64,12 @@ public static async Task CreateAsync(IEnumerable< }); } - return new BrowserFileFormFileCollection(formFiles, streams); + return new BrowserFileFormFiles(formFiles, streams); } + /// + /// Disposes the streams that back the form files. + /// public void Dispose() { foreach (var stream in _streams) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Tools/SendEmailTool.cs b/src/Startup/CrestApps.Core.Blazor.Web/Tools/SendEmailTool.cs index c371180a..9e4ab1d4 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Tools/SendEmailTool.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Tools/SendEmailTool.cs @@ -6,6 +6,13 @@ namespace CrestApps.Core.Blazor.Web.Tools; public sealed class SendEmailTool : AIFunction { public const string TheName = "sendEmail"; + + private static readonly Action _sendEmailToolInvoked = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1001, nameof(SendEmailToolInvoked)), + "Blazor sendEmail tool invoked. To: {To}; Subject: {Subject}; Message: {Message}"); + private static readonly JsonElement _jsonSchema = JsonSerializer.Deserialize(""" { "type": "object", @@ -49,7 +56,7 @@ protected override ValueTask InvokeCoreAsync(AIFunctionArguments argumen var logger = arguments.Services.GetRequiredService>(); if (logger.IsEnabled(LogLevel.Information)) { - logger.LogInformation("Blazor sendEmail tool invoked. To: {To}; Subject: {Subject}; Message: {Message}", TryGetOptionalString(arguments, "to"), subject, message); + SendEmailToolInvoked(logger, TryGetOptionalString(arguments, "to"), subject, message); } return ValueTask.FromResult(JsonSerializer.Serialize(new { success = true, subject, })); @@ -66,4 +73,9 @@ private static bool TryGetRequiredString(AIFunctionArguments arguments, string k return !string.IsNullOrWhiteSpace(value); } + + private static void SendEmailToolInvoked(ILogger logger, string to, string subject, string message) + { + _sendEmailToolInvoked(logger, to, subject, message, null); + } } diff --git a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml index 003dc24c..d1d2f0a5 100644 --- a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml @@ -32,6 +32,7 @@ var headerId = $"header-agent-{i}"; var resultId = $"result-agent-{i}"; var hasSkills = card.Skills?.Count > 0; + var agentUrl = card.SupportedInterfaces?.FirstOrDefault()?.Url;

@@ -50,18 +51,20 @@ Version: @card.Version } - @if (hasSkills) - { -
- Skills: - @foreach (var skill in card.Skills) - { - @(skill.Name ?? skill.Id) - } -
- } +
+ @if (hasSkills) + { +
+ + +
+ } -
@@ -97,7 +100,8 @@ else if (string.IsNullOrWhiteSpace(Model.ErrorMessage)) btn.addEventListener('click', function () { var form = btn.closest('.agent-form'); var agentUrl = form.dataset.agentUrl || ''; - var agentName = form.dataset.agentName || ''; + var skillSelect = form.querySelector('.agent-skill'); + var agentName = skillSelect && skillSelect.value ? skillSelect.value : (form.dataset.agentName || ''); var resultDiv = document.querySelector(form.dataset.resultTarget); var messageInput = form.querySelector('.agent-message'); var streamCheck = form.querySelector('.agent-stream-check'); diff --git a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml.cs b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml.cs index be5ad528..d2494970 100644 --- a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml.cs +++ b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Pages/Agents.cshtml.cs @@ -3,16 +3,59 @@ using CrestApps.Core.Mvc.Samples.A2AClient.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using SampleA2AClientFactory = CrestApps.Core.Mvc.Samples.A2AClient.Services.A2AClientFactory; namespace CrestApps.Core.Mvc.Samples.A2AClient.Pages; public sealed class AgentsModel : PageModel { - private readonly A2AClientFactory _clientFactory; + private static readonly Action _authenticationFailed = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1001, nameof(AuthenticationFailed)), + "Authentication failed when communicating with the A2A agent."); + + private static readonly Action _accessDenied = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1002, nameof(AccessDenied)), + "Access denied when communicating with the A2A agent."); + + private static readonly Action _failedToCommunicate = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1003, nameof(FailedToCommunicate)), + "Failed to communicate with the A2A agent at '{AgentUrl}'."); + + private static readonly Action _failedToLoadAgentCards = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1004, nameof(FailedToLoadAgentCards)), + "Failed to load agent cards."); + + private static readonly Action _streamingAuthenticationFailed = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1005, "StreamingAuthenticationFailed"), + "Authentication failed during streaming."); + + private static readonly Action _streamingAccessDenied = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1006, "StreamingAccessDenied"), + "Access denied during streaming."); + + private static readonly Action _streamingError = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1007, "StreamingError"), + "Error during A2A streaming."); + + private readonly SampleA2AClientFactory _clientFactory; private readonly ILogger _logger; public AgentsModel( - A2AClientFactory clientFactory, + SampleA2AClientFactory clientFactory, ILogger logger) { _clientFactory = clientFactory; @@ -48,12 +91,12 @@ public async Task OnPostSendMessageAsync(string agentUrl, string { var client = _clientFactory.Create(agentUrl); - var agentMessage = new AgentMessage + var agentMessage = new Message { - Role = MessageRole.User, + Role = Role.User, MessageId = Guid.NewGuid().ToString(), ContextId = Guid.NewGuid().ToString(), - Parts = [new TextPart { Text = message }], + Parts = [Part.FromText(message)], }; if (!string.IsNullOrWhiteSpace(agentName)) @@ -64,17 +107,17 @@ public async Task OnPostSendMessageAsync(string agentUrl, string }; } - var sendParams = new MessageSendParams + var sendRequest = new SendMessageRequest { Message = agentMessage, }; if (stream) { - return new StreamingA2AResult(client, sendParams, HttpContext.RequestServices.GetRequiredService>()); + return new StreamingA2AResult(client, sendRequest, HttpContext.RequestServices.GetRequiredService>()); } - var response = await client.SendMessageAsync(sendParams, cancellationToken); + var response = await client.SendMessageAsync(sendRequest, cancellationToken); var responseText = ExtractTextFromResponse(response); @@ -82,17 +125,16 @@ public async Task OnPostSendMessageAsync(string agentUrl, string } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - _logger.LogWarning(ex, "Authentication failed when communicating with the A2A agent."); + AuthenticationFailed(_logger, ex); return new JsonResult(new { - error = "Authentication failed (401 Unauthorized). " + - "The A2A host requires authentication. Check the agent card's security schemes for details." + error = CreateAuthenticationErrorMessage() }); } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden) { - _logger.LogWarning(ex, "Access denied when communicating with the A2A agent."); + AccessDenied(_logger, ex); return new JsonResult(new { @@ -107,32 +149,68 @@ public async Task OnPostSendMessageAsync(string agentUrl, string error = $"The selected server '{selectedServer.DisplayName}' did not expose an A2A host at '{selectedServer.Endpoint.TrimEnd('/')}/a2a'." }); } + catch (HttpRequestException ex) when (IsRedirectStatusCode(ex.StatusCode)) + { + return new JsonResult(new + { + error = CreateRedirectErrorMessage() + }); + } + catch (JsonException ex) + { + FailedToCommunicate(_logger, agentUrl, ex); + + return new JsonResult(new + { + error = CreateNonJsonResponseErrorMessage() + }); + } + catch (HttpRequestException ex) + { + FailedToCommunicate(_logger, agentUrl, ex); + + return new JsonResult(new + { + error = CreateConnectionErrorMessage(selectedServer.DisplayName, agentUrl, ex) + }); + } catch (Exception ex) { - _logger.LogError(ex, "Failed to communicate with the A2A agent at '{AgentUrl}'.", agentUrl); + FailedToCommunicate(_logger, agentUrl, ex); return new JsonResult(new { error = $"An error occurred while communicating with the agent: {ex.Message}" }); } } - private static string ExtractTextFromResponse(A2AResponse response) + private static bool IsRedirectStatusCode(System.Net.HttpStatusCode? statusCode) { - if (response is AgentMessage message) + return statusCode is + System.Net.HttpStatusCode.Moved or + System.Net.HttpStatusCode.Redirect or + System.Net.HttpStatusCode.RedirectMethod or + System.Net.HttpStatusCode.TemporaryRedirect or + System.Net.HttpStatusCode.PermanentRedirect; + } + + private static string ExtractTextFromResponse(SendMessageResponse response) + { + if (response.Message is { } message) { - var texts = message.Parts?.OfType().Select(p => p.Text); + var texts = message.Parts.Select(p => p.Text).OfType(); - if (texts?.Any() == true) + if (texts.Any()) { return string.Join(string.Empty, texts); } } - else if (response is AgentTask task) + else if (response.Task is { } task) { if (task.Artifacts?.Count > 0) { var artifactTexts = task.Artifacts - .SelectMany(a => a.Parts?.OfType() ?? []) - .Select(p => p.Text); + .SelectMany(a => a.Parts ?? []) + .Select(p => p.Text) + .OfType(); var combined = string.Join(string.Empty, artifactTexts); @@ -144,7 +222,9 @@ private static string ExtractTextFromResponse(A2AResponse response) if (task.Status.Message?.Parts is not null) { - var statusTexts = task.Status.Message.Parts.OfType().Select(p => p.Text); + var statusTexts = task.Status.Message.Parts + .Select(p => p.Text) + .OfType(); var combined = string.Join(string.Empty, statusTexts); @@ -166,17 +246,94 @@ private async Task LoadAgentCardsAsync(CancellationToken cancellationToken) { AgentCards = await _clientFactory.GetAgentCardsAsync(cancellationToken); } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + AuthenticationFailed(_logger, ex); + ErrorMessage = CreateAuthenticationErrorMessage(); + } + catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + AccessDenied(_logger, ex); + ErrorMessage = "Access denied (403 Forbidden). You do not have permission to access this A2A host."; + } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { ErrorMessage = $"The selected server '{selectedServer.DisplayName}' did not expose an A2A host at '{selectedServer.Endpoint.TrimEnd('/')}/.well-known/agent-card.json'."; } + catch (HttpRequestException ex) when (IsRedirectStatusCode(ex.StatusCode)) + { + ErrorMessage = CreateRedirectErrorMessage(); + } + catch (JsonException ex) + { + FailedToLoadAgentCards(_logger, ex); + ErrorMessage = CreateNonJsonResponseErrorMessage(); + } + catch (HttpRequestException ex) + { + FailedToLoadAgentCards(_logger, ex); + ErrorMessage = CreateConnectionErrorMessage( + selectedServer.DisplayName, + selectedServer.Endpoint.TrimEnd('/') + "/.well-known/agent-card.json", + ex); + } catch (Exception ex) { - _logger.LogError(ex, "Failed to load agent cards."); + FailedToLoadAgentCards(_logger, ex); ErrorMessage = $"An error occurred while loading agent cards from '{selectedServer.DisplayName}': {ex.Message}"; } } + private static string CreateAuthenticationErrorMessage() + { + return "Authentication failed (401 Unauthorized). The A2A host requires authentication. " + + "Configure the host for API key authentication and add the API key to this sample, " + + "or set A2A host authentication to None for local testing."; + } + + private static string CreateRedirectErrorMessage() + { + return "The A2A host redirected the protocol request instead of returning an A2A JSON response. " + + "This usually means the host requires an interactive login. Configure the host for API key authentication " + + "and add the API key to the sample client server settings, or disable A2A host authentication for local testing."; + } + + private static string CreateNonJsonResponseErrorMessage() + { + return "The A2A host returned content that was not a valid A2A JSON response. " + + "This usually means the request reached an HTML login, access denied, or error page. " + + "Check the host authentication settings and the configured A2A endpoint URL."; + } + + private static string CreateConnectionErrorMessage(string serverName, string endpoint, HttpRequestException exception) + { + var rootMessage = exception.GetBaseException().Message; + + return $"Could not connect to '{serverName}' at '{endpoint}'. {rootMessage} " + + "For local sample hosts, make sure the configured endpoint uses the HTTPS URL from the target project's launchSettings.json " + + "and that the ASP.NET Core development certificate is trusted."; + } + + private static void AuthenticationFailed(ILogger logger, Exception exception) + { + _authenticationFailed(logger, exception); + } + + private static void AccessDenied(ILogger logger, Exception exception) + { + _accessDenied(logger, exception); + } + + private static void FailedToCommunicate(ILogger logger, string agentUrl, Exception exception) + { + _failedToCommunicate(logger, agentUrl, exception); + } + + private static void FailedToLoadAgentCards(ILogger logger, Exception exception) + { + _failedToLoadAgentCards(logger, exception); + } + /// /// Custom that streams A2A events as text/event-stream /// so the browser receives chunks incrementally. @@ -184,16 +341,16 @@ private async Task LoadAgentCardsAsync(CancellationToken cancellationToken) private sealed class StreamingA2AResult : IActionResult { private readonly A2A.A2AClient _client; - private readonly MessageSendParams _sendParams; + private readonly SendMessageRequest _sendRequest; private readonly ILogger _logger; public StreamingA2AResult( A2A.A2AClient client, - MessageSendParams sendParams, + SendMessageRequest sendRequest, ILogger logger) { _client = client; - _sendParams = sendParams; + _sendRequest = sendRequest; _logger = logger; } @@ -208,27 +365,26 @@ public async Task ExecuteResultAsync(ActionContext context) try { - await foreach (var sseItem in _client.SendMessageStreamingAsync(_sendParams, cancellationToken)) + await foreach (var streamEvent in _client.SendStreamingMessageAsync(_sendRequest, cancellationToken)) { - var a2aEvent = sseItem.Data; string chunk = null; - if (a2aEvent is TaskArtifactUpdateEvent artifactUpdate) + if (streamEvent.ArtifactUpdate is { } artifactUpdate) { chunk = string.Join(string.Empty, - artifactUpdate.Artifact?.Parts?.OfType().Select(p => p.Text) ?? []); + artifactUpdate.Artifact.Parts.Select(p => p.Text).OfType()); } - else if (a2aEvent is TaskStatusUpdateEvent statusUpdate) + else if (streamEvent.StatusUpdate is { } statusUpdate) { - if (statusUpdate.Final) + if (IsTerminalState(statusUpdate.Status.State)) { // If the task failed, send the error message. if (statusUpdate.Status.State == TaskState.Failed) { var errorText = statusUpdate.Status.Message?.Parts - ?.OfType() - .Select(p => p.Text) + ?.Select(p => p.Text) + .OfType() .FirstOrDefault() ?? "Agent task failed."; await httpResponse.WriteAsync($"data: [ERROR]{errorText}\n\n", cancellationToken); @@ -256,17 +412,27 @@ public async Task ExecuteResultAsync(ActionContext context) } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Unauthorized) { - _logger.LogWarning(ex, "Authentication failed during streaming."); - await WriteErrorAsync(httpResponse, "Authentication failed (401 Unauthorized)."); + StreamingAuthenticationFailed(_logger, ex); + await WriteErrorAsync(httpResponse, CreateAuthenticationErrorMessage()); } catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden) { - _logger.LogWarning(ex, "Access denied during streaming."); + StreamingAccessDenied(_logger, ex); await WriteErrorAsync(httpResponse, "Access denied (403 Forbidden)."); } + catch (HttpRequestException ex) when (IsRedirectStatusCode(ex.StatusCode)) + { + StreamingAuthenticationFailed(_logger, ex); + await WriteErrorAsync(httpResponse, CreateRedirectErrorMessage()); + } + catch (JsonException ex) + { + StreamingError(_logger, ex); + await WriteErrorAsync(httpResponse, CreateNonJsonResponseErrorMessage()); + } catch (Exception ex) { - _logger.LogError(ex, "Error during A2A streaming."); + StreamingError(_logger, ex); await WriteErrorAsync(httpResponse, ex.Message); } } @@ -283,5 +449,30 @@ private static async Task WriteErrorAsync(HttpResponse httpResponse, string mess // Response may already be completed. } } + + private static bool IsTerminalState(TaskState state) + { + return state is TaskState.Completed + or TaskState.Failed + or TaskState.Canceled + or TaskState.InputRequired + or TaskState.Rejected + or TaskState.AuthRequired; + } + + private static void StreamingAuthenticationFailed(ILogger logger, Exception exception) + { + _streamingAuthenticationFailed(logger, exception); + } + + private static void StreamingAccessDenied(ILogger logger, Exception exception) + { + _streamingAccessDenied(logger, exception); + } + + private static void StreamingError(ILogger logger, Exception exception) + { + _streamingError(logger, exception); + } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Program.cs b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Program.cs index 2a14f6ad..be7f77a8 100644 --- a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Program.cs @@ -5,6 +5,11 @@ builder.Services.AddRazorPages(); builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(A2AClientFactory.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false, + }); builder.Services.AddHttpContextAccessor(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => new SampleServerSelectionService( diff --git a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Services/A2AClientFactory.cs b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Services/A2AClientFactory.cs index bf673200..90d14f4d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Services/A2AClientFactory.cs +++ b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/Services/A2AClientFactory.cs @@ -7,6 +7,8 @@ namespace CrestApps.Core.Mvc.Samples.A2AClient.Services; public sealed class A2AClientFactory { + public const string HttpClientName = "A2AProtocolClient"; + private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -30,8 +32,11 @@ public A2AClientFactory( public A2A.A2AClient Create(string agentUrl = null) { var server = GetSelectedServer(); - var url = agentUrl ?? server.Endpoint.TrimEnd('/') + "/a2a"; - var httpClient = _httpClientFactory.CreateClient(); + var url = string.IsNullOrWhiteSpace(agentUrl) + ? server.Endpoint.TrimEnd('/') + "/a2a" + : agentUrl; + + var httpClient = _httpClientFactory.CreateClient(HttpClientName); ApplyAuthentication(httpClient, server); return new A2A.A2AClient(new Uri(url), httpClient); @@ -40,7 +45,7 @@ public A2A.A2AClient Create(string agentUrl = null) public async Task> GetAgentCardsAsync(CancellationToken cancellationToken) { var server = GetSelectedServer(); - var httpClient = _httpClientFactory.CreateClient(); + var httpClient = _httpClientFactory.CreateClient(HttpClientName); ApplyAuthentication(httpClient, server); var cardUrl = $"{server.Endpoint.TrimEnd('/')}/.well-known/agent-card.json"; diff --git a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/appsettings.json b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/appsettings.json index 38d91e59..020a8e22 100644 --- a/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/appsettings.json +++ b/src/Startup/CrestApps.Core.Mvc.Samples.A2AClient/appsettings.json @@ -4,11 +4,11 @@ "Servers": { "MvcWeb": { "DisplayName": "MVC Web", - "Endpoint": "https://localhost:5001" + "Endpoint": "https://localhost:5100" }, "BlazorWeb": { "DisplayName": "Blazor Web", - "Endpoint": "https://localhost:5201" + "Endpoint": "https://localhost:5200" } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 00fb55dd..8a9c32f6 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -1,6 +1,7 @@ using CrestApps.Core; using CrestApps.Core.AI; using CrestApps.Core.AI.A2A; +using CrestApps.Core.AI.A2A.Models; using CrestApps.Core.AI.Azure.AISearch; using CrestApps.Core.AI.AzureAIInference; using CrestApps.Core.AI.Chat; @@ -92,6 +93,32 @@ { options.LoginPath = "/Account/Login"; options.AccessDeniedPath = "/Account/AccessDenied"; + options.Events.OnRedirectToLogin = context => + { + if (IsA2AProtocolRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + if (IsA2AProtocolRequest(context.Request)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + + return Task.CompletedTask; + }; }); builder.Services.AddAuthorizationBuilder() @@ -161,6 +188,9 @@ ) ); +builder.Services.Configure( + builder.Configuration.GetSection(nameof(A2AHostOptions))); + // ============================================================================= // 4. MCP AND CUSTOM TOOLS // ============================================================================= @@ -280,3 +310,8 @@ app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); await app.RunAsync(); + +static bool IsA2AProtocolRequest(HttpRequest request) +{ + return request.Path.StartsWithSegments("/a2a"); +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs index ea291ddd..2a5d13e1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/A2AHostExtensions.cs @@ -26,6 +26,12 @@ internal static class A2AHostExtensions DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + private static readonly Action _failedToExecuteAgent = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1001, nameof(FailedToExecuteAgent)), + "Failed to execute agent '{AgentName}'."); + public static IServiceCollection AddA2AHost(this IServiceCollection services) { services.AddScoped(); @@ -42,7 +48,15 @@ public static IServiceCollection AddA2AHost(this IServiceCollection services) policy.AddRequirements(new A2AHostAuthorizationRequirement()); }); - services.AddSingleton(CreateTaskManager); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(services => + new A2AServer( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService>())); return services; } @@ -58,47 +72,13 @@ public static IEndpointRouteBuilder MapA2AHost(this IEndpointRouteBuilder endpoi { endpoints.MapGet("/.well-known/agent-card.json", HandleWellKnownEndpointAsync); - var taskManager = endpoints.ServiceProvider.GetRequiredService(); - endpoints.MapA2A(taskManager, "a2a") + var requestHandler = endpoints.ServiceProvider.GetRequiredService(); + endpoints.MapA2A(requestHandler, "a2a") .RequireAuthorization(A2AHostPolicyName); return endpoints; } - private static ITaskManager CreateTaskManager(IServiceProvider serviceProvider) - { - var httpContextAccessor = serviceProvider.GetRequiredService(); - var taskManager = new TaskManager(); - - taskManager.OnAgentCardQuery = async (agentUrl, cancellationToken) => - { - var services = httpContextAccessor.HttpContext!.RequestServices; - var options = services.GetRequiredService>().CurrentValue; - var profileManager = services.GetRequiredService(); - var profiles = await profileManager.GetAsync(AIProfileType.Agent, cancellationToken); - - if (options.ExposeAgentsAsSkill) - { - return BuildSkillModeCard(agentUrl, profiles); - } - - var agentName = httpContextAccessor.HttpContext?.Request.Query["agent"].FirstOrDefault(); - var targetProfile = ResolveAgentProfile(profiles, agentName); - - return targetProfile is not null - ? BuildAgentCard(targetProfile, agentUrl) - : BuildSkillModeCard(agentUrl, profiles); - }; - - taskManager.OnTaskCreated = (agentTask, cancellationToken) => - ProcessAgentTaskAsync(taskManager, httpContextAccessor, agentTask, cancellationToken); - - taskManager.OnTaskUpdated = (agentTask, cancellationToken) => - ProcessAgentTaskAsync(taskManager, httpContextAccessor, agentTask, cancellationToken); - - return taskManager; - } - private static async Task HandleWellKnownEndpointAsync(HttpContext context) { var options = context.RequestServices.GetRequiredService>().CurrentValue; @@ -133,53 +113,44 @@ private static async Task HandleWellKnownEndpointAsync(HttpContext context) } } - private static async Task ProcessAgentTaskAsync( - TaskManager taskManager, + private static async Task ProcessAgentRequestAsync( + TaskUpdater updater, IHttpContextAccessor httpContextAccessor, - AgentTask agentTask, + RequestContext requestContext, + AgentEventQueue eventQueue, CancellationToken cancellationToken) { var services = httpContextAccessor.HttpContext?.RequestServices; if (services is null) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "Request services are not available."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "Request services are not available."), cancellationToken: cancellationToken); return; } - var logger = services.GetRequiredService>(); + var logger = services.GetRequiredService>(); - var lastMessage = agentTask.History?.LastOrDefault(); - var prompt = lastMessage?.Parts?.OfType().FirstOrDefault()?.Text; + var prompt = requestContext.UserText; if (string.IsNullOrWhiteSpace(prompt)) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "No text message was provided."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "No text message was provided."), cancellationToken: cancellationToken); return; } var targetProfile = await ResolveTargetProfileAsync( - services, httpContextAccessor, lastMessage); + services, httpContextAccessor, requestContext.Message); if (targetProfile is null) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, "No agents are available to process this request."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, "No agents are available to process this request."), cancellationToken: cancellationToken); return; @@ -187,10 +158,12 @@ await taskManager.UpdateStatusAsync( try { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Working, - cancellationToken: cancellationToken); + if (!requestContext.IsContinuation) + { + await updater.SubmitAsync(cancellationToken: cancellationToken); + } + + await updater.StartWorkAsync(cancellationToken: cancellationToken); var completionService = services.GetRequiredService(); var contextBuilder = services.GetRequiredService(); @@ -208,6 +181,8 @@ await taskManager.UpdateStatusAsync( }; var responseText = new System.Text.StringBuilder(); + var artifactId = Guid.NewGuid().ToString("N"); + var appendArtifact = false; await foreach (var update in completionService.CompleteStreamingAsync( deployment, messages, context, cancellationToken)) @@ -218,13 +193,14 @@ await taskManager.UpdateStatusAsync( { responseText.Append(chunk); - await taskManager.ReturnArtifactAsync( - agentTask.Id, - new Artifact - { - Parts = [new TextPart { Text = chunk }], - }, - cancellationToken); + await updater.AddArtifactAsync( + [Part.FromText(chunk)], + artifactId: artifactId, + append: appendArtifact, + lastChunk: false, + cancellationToken: cancellationToken); + + appendArtifact = true; } } @@ -232,30 +208,38 @@ await taskManager.ReturnArtifactAsync( ? responseText.ToString() : "The agent did not produce a response."; - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Completed, - CreateAgentMessage(agentTask.ContextId, finalText), - final: true, + if (appendArtifact) + { + await eventQueue.EnqueueArtifactUpdateAsync( + new TaskArtifactUpdateEvent + { + TaskId = updater.TaskId, + ContextId = updater.ContextId, + Artifact = new Artifact + { + ArtifactId = artifactId, + Parts = [], + }, + Append = true, + LastChunk = true, + }, + cancellationToken); + } + + await updater.CompleteAsync( + CreateAgentMessage(requestContext.ContextId, finalText), cancellationToken: cancellationToken); } catch (OperationCanceledException) { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Canceled, - final: true, - cancellationToken: CancellationToken.None); + await updater.CancelAsync(cancellationToken: CancellationToken.None); } catch (Exception ex) { - logger.LogError(ex, "Failed to execute agent '{AgentName}'.", targetProfile.Name); + FailedToExecuteAgent(logger, targetProfile.Name, ex); - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - CreateAgentMessage(agentTask.ContextId, $"An error occurred while executing agent '{targetProfile.Name}'."), - final: true, + await updater.FailAsync( + CreateAgentMessage(requestContext.ContextId, $"An error occurred while executing agent '{targetProfile.Name}'."), cancellationToken: CancellationToken.None); } } @@ -263,7 +247,7 @@ await taskManager.UpdateStatusAsync( private static async Task ResolveTargetProfileAsync( IServiceProvider services, IHttpContextAccessor httpContextAccessor, - AgentMessage lastMessage) + Message message) { var options = services.GetRequiredService>().CurrentValue; var profileManager = services.GetRequiredService(); @@ -283,7 +267,7 @@ private static async Task ResolveTargetProfileAsync( } if (targetProfile is null && - lastMessage?.Metadata?.TryGetValue("agentName", out var agentNameElement) == true) + message?.Metadata?.TryGetValue("agentName", out var agentNameElement) == true) { var metaAgentName = agentNameElement.GetString(); @@ -319,10 +303,18 @@ private static AgentCard BuildSkillModeCard(string agentUrl, IEnumerable { - ["apiKey"] = new ApiKeySecurityScheme( - name: "Authorization", - keyLocation: "header", - description: "API key authentication. Send as 'Bearer {key}' or 'ApiKey {key}' in the Authorization header."), + ["apiKey"] = new SecurityScheme + { + ApiKeySecurityScheme = new ApiKeySecurityScheme + { + Name = "Authorization", + Location = "header", + Description = "API key authentication. Send as 'Bearer {key}' or 'ApiKey {key}' in the Authorization header.", + }, + }, }; - card.Security = + card.SecurityRequirements = [ - new Dictionary { ["apiKey"] = [] }, + new SecurityRequirement + { + Schemes = new Dictionary + { + ["apiKey"] = new(), + }, + }, ]; break; case A2AHostAuthenticationType.OpenId: card.SecuritySchemes = new Dictionary { - ["openId"] = new OpenIdConnectSecurityScheme( - openIdConnectUrl: new Uri($"{baseUrl}/.well-known/openid-configuration"), - description: "OpenID Connect authentication."), + ["openId"] = new SecurityScheme + { + OpenIdConnectSecurityScheme = new OpenIdConnectSecurityScheme + { + OpenIdConnectUrl = $"{baseUrl}/.well-known/openid-configuration", + Description = "OpenID Connect authentication.", + }, + }, }; - card.Security = + card.SecurityRequirements = [ - new Dictionary { ["openId"] = [] }, + new SecurityRequirement + { + Schemes = new Dictionary + { + ["openId"] = new(), + }, + }, ]; break; } @@ -394,14 +426,44 @@ private static AIProfile ResolveAgentProfile(IEnumerable profiles, st string.Equals(p.Name, agentName, StringComparison.OrdinalIgnoreCase)); } - private static AgentMessage CreateAgentMessage(string contextId, string text) + private static Message CreateAgentMessage(string contextId, string text) { - return new AgentMessage + return new Message { - Role = MessageRole.Agent, + Role = Role.Agent, MessageId = Guid.NewGuid().ToString(), ContextId = contextId, - Parts = [new TextPart { Text = text }], + Parts = [Part.FromText(text)], }; } + + private static void FailedToExecuteAgent(ILogger logger, string agentName, Exception exception) + { + _failedToExecuteAgent(logger, agentName, exception); + } + + private sealed class MvcA2AAgentHandler : IAgentHandler + { + private readonly IHttpContextAccessor _httpContextAccessor; + + public MvcA2AAgentHandler(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public Task ExecuteAsync( + RequestContext context, + AgentEventQueue eventQueue, + CancellationToken cancellationToken) + { + var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + + return ProcessAgentRequestAsync( + updater, + _httpContextAccessor, + context, + eventQueue, + cancellationToken); + } + } } diff --git a/tests/CrestApps.Core.Tests/Core/Services/A2AAgentProxyToolTests.cs b/tests/CrestApps.Core.Tests/Core/Services/A2AAgentProxyToolTests.cs index 3a56464e..2f3ea52c 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/A2AAgentProxyToolTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/A2AAgentProxyToolTests.cs @@ -36,11 +36,11 @@ public void Metadata_MultipleInstances_PreservesSchemaAndPropertyIsolation() [Fact] public void ExtractTextFromResponse_AgentMessage_ConcatenatesTextPartsExactly() { - var response = CreateMessage( - new TextPart { Text = "first" }, - new TextPart { Text = null }, - new TextPart { Text = string.Empty }, - new TextPart { Text = "second" }); + var response = CreateMessageResponse( + Part.FromText("first"), + new Part(), + Part.FromText(string.Empty), + Part.FromText("second")); var result = ExtractTextFromResponse(response); @@ -48,16 +48,16 @@ public void ExtractTextFromResponse_AgentMessage_ConcatenatesTextPartsExactly() } /// - /// Verifies that a message containing a null text part returns the legacy empty-string result. + /// Verifies that a message without text returns the null result used by the proxy fallback. /// [Fact] - public void ExtractTextFromResponse_AgentMessageWithNullText_ReturnsEmpty() + public void ExtractTextFromResponse_AgentMessageWithoutText_ReturnsNull() { - var response = CreateMessage(new TextPart { Text = null }); + var response = CreateMessageResponse(new Part()); var result = ExtractTextFromResponse(response); - Assert.Equal(string.Empty, result); + Assert.Null(result); } /// @@ -73,17 +73,17 @@ public void ExtractTextFromResponse_AgentTask_PrefersArtifactText() ArtifactId = "first", Parts = [ - new TextPart { Text = "artifact-" }, - new TextPart { Text = null }, + Part.FromText("artifact-"), + new Part(), ], }, new Artifact { ArtifactId = "second", - Parts = [new TextPart { Text = "text" }], + Parts = [Part.FromText("text")], }, ], - CreateMessage(new TextPart { Text = "status" })); + CreateMessage(Part.FromText("status"))); var result = ExtractTextFromResponse(response); @@ -103,14 +103,14 @@ public void ExtractTextFromResponse_AgentTaskWithEmptyArtifacts_ReturnsStatusTex ArtifactId = "empty", Parts = [ - new TextPart { Text = null }, - new TextPart { Text = string.Empty }, + new Part(), + Part.FromText(string.Empty), ], }, ], CreateMessage( - new TextPart { Text = "status-" }, - new TextPart { Text = "text" })); + Part.FromText("status-"), + Part.FromText("text"))); var result = ExtractTextFromResponse(response); @@ -123,7 +123,7 @@ public void ExtractTextFromResponse_AgentTaskWithEmptyArtifacts_ReturnsStatusTex [Fact] public void ExtractTextFromResponse_ResponseWithoutText_ReturnsNull() { - var messageResult = ExtractTextFromResponse(CreateMessage()); + var messageResult = ExtractTextFromResponse(CreateMessageResponse()); var taskResult = ExtractTextFromResponse(CreateTask([], CreateMessage())); Assert.Null(messageResult); @@ -144,16 +144,29 @@ private static A2AAgentProxyTool CreateTool(string name) "connection"); } + /// + /// Creates an A2A message response with the supplied parts. + /// + /// The message parts. + /// The A2A message response. + private static SendMessageResponse CreateMessageResponse(params Part[] parts) + { + return new SendMessageResponse + { + Message = CreateMessage(parts), + }; + } + /// /// Creates an agent message with the supplied parts. /// /// The message parts. /// The agent message. - private static AgentMessage CreateMessage(params Part[] parts) + private static Message CreateMessage(params Part[] parts) { - return new AgentMessage + return new Message { - Role = MessageRole.Agent, + Role = Role.Agent, MessageId = "message", Parts = [.. parts], }; @@ -164,20 +177,23 @@ private static AgentMessage CreateMessage(params Part[] parts) /// /// The task artifacts. /// The task status message. - /// The agent task. - private static AgentTask CreateTask( + /// The A2A task response. + private static SendMessageResponse CreateTask( List artifacts, - AgentMessage statusMessage) + Message statusMessage) { - return new AgentTask + return new SendMessageResponse { - Id = "task", - ContextId = "context", - Artifacts = artifacts, - Status = new AgentTaskStatus + Task = new AgentTask { - State = TaskState.Completed, - Message = statusMessage, + Id = "task", + ContextId = "context", + Artifacts = artifacts, + Status = new A2A.TaskStatus + { + State = TaskState.Completed, + Message = statusMessage, + }, }, }; } @@ -187,7 +203,7 @@ private static AgentTask CreateTask( /// /// The A2A response. /// The extracted response text. - private static string ExtractTextFromResponse(A2AResponse response) + private static string ExtractTextFromResponse(SendMessageResponse response) { return A2AAgentProxyTool.ExtractTextFromResponse(response); } diff --git a/tests/CrestApps.Core.Tests/Core/Services/A2AToolRegistryProviderTests.cs b/tests/CrestApps.Core.Tests/Core/Services/A2AToolRegistryProviderTests.cs index ff77211a..cd291e4a 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/A2AToolRegistryProviderTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/A2AToolRegistryProviderTests.cs @@ -203,7 +203,13 @@ public async Task GetToolsAsync_SkipsUnavailableEntriesAndContinuesAfterCacheFai { Name = "No Skills", Description = "No skills", - Url = "https://example.com", + SupportedInterfaces = + [ + new AgentInterface + { + Url = "https://example.com", + }, + ], Version = "1.0", Skills = null, }); @@ -349,7 +355,13 @@ private static AgentCard CreateCard(string description, params AgentSkill[] skil { Name = "Agent", Description = description, - Url = "https://example.com", + SupportedInterfaces = + [ + new AgentInterface + { + Url = "https://example.com", + }, + ], Version = "1.0", Skills = [.. skills], }; diff --git a/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj b/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj index e0f066a5..9cdd3b91 100644 --- a/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj +++ b/tests/CrestApps.Core.Tests/CrestApps.Core.Tests.csproj @@ -5,7 +5,7 @@ false true $(PackageTags) tests xunit unit-tests integration-tests - $(NoWarn);CA1852 + $(NoWarn);CA1707;CA1848;CA1852 true true