diff --git a/BotSharp.sln b/BotSharp.sln index b87060e96..5d3ce6b54 100644 --- a/BotSharp.sln +++ b/BotSharp.sln @@ -153,7 +153,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MMPEmbeddin EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.Membase", "src\Plugins\BotSharp.Plugin.Membase\BotSharp.Plugin.Membase.csproj", "{13223C71-9EAC-9835-28ED-5A4833E6F915}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MultiTenancy", "MultiTenancy", "{7C64208C-8D11-4E17-A3E9-14D7910763EB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Core.A2A", "src\Infrastructure\BotSharp.Core.A2A\BotSharp.Core.A2A.csproj", "{E8D01281-D52A-BFF4-33DB-E35D91754272}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BotSharp.Plugin.MultiTenancy", "src\Plugins\BotSharp.Plugin.MultiTenancy\BotSharp.Plugin.MultiTenancy.csproj", "{9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}" EndProject @@ -653,6 +653,14 @@ Global {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|Any CPU.Build.0 = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.ActiveCfg = Release|Any CPU {13223C71-9EAC-9835-28ED-5A4833E6F915}.Release|x64.Build.0 = Release|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Debug|x64.ActiveCfg = Debug|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Debug|x64.Build.0 = Debug|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Release|Any CPU.Build.0 = Release|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Release|x64.ActiveCfg = Release|Any CPU + {E8D01281-D52A-BFF4-33DB-E35D91754272}.Release|x64.Build.0 = Release|Any CPU {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|Any CPU.Build.0 = Debug|Any CPU {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -735,7 +743,7 @@ Global {E7C243B9-E751-B3B4-8F16-95C76CA90D31} = {51AFE054-AE99-497D-A593-69BAEFB5106F} {394B858B-9C26-B977-A2DA-8CC7BE5914CB} = {4F346DCE-087F-4368-AF88-EE9C720D0E69} {13223C71-9EAC-9835-28ED-5A4833E6F915} = {53E7CD86-0D19-40D9-A0FA-AB4613837E89} - {7C64208C-8D11-4E17-A3E9-14D7910763EB} = {2635EC9B-2E5F-4313-AC21-0B847F31F36C} + {E8D01281-D52A-BFF4-33DB-E35D91754272} = {E29DC6C4-5E57-48C5-BCB0-6B8F84782749} {9BC8DF43-88D1-4C57-A678-AC0153BDF4EB} = {7C64208C-8D11-4E17-A3E9-14D7910763EB} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/Directory.Packages.props b/Directory.Packages.props index 35f37271c..1c198a828 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,7 @@ true + diff --git a/src/Infrastructure/BotSharp.Abstraction/Agents/Enums/AgentType.cs b/src/Infrastructure/BotSharp.Abstraction/Agents/Enums/AgentType.cs index 3b9767845..9f79ef333 100644 --- a/src/Infrastructure/BotSharp.Abstraction/Agents/Enums/AgentType.cs +++ b/src/Infrastructure/BotSharp.Abstraction/Agents/Enums/AgentType.cs @@ -23,5 +23,10 @@ public static class AgentType /// Agent that cannot use external tools /// public const string Static = "static"; + + /// + /// A2A remote agent for Microsoft Agent Framework integration + /// + public const string A2ARemote = "a2a-remote"; } diff --git a/src/Infrastructure/BotSharp.Core.A2A/A2APlugin.cs b/src/Infrastructure/BotSharp.Core.A2A/A2APlugin.cs new file mode 100644 index 000000000..f8a5553c0 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/A2APlugin.cs @@ -0,0 +1,36 @@ +using BotSharp.Abstraction.Agents; +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Functions; +using BotSharp.Abstraction.Plugins; +using BotSharp.Abstraction.Settings; +using BotSharp.Core.A2A.Functions; +using BotSharp.Core.A2A.Hooks; +using BotSharp.Core.A2A.Services; +using BotSharp.Core.A2A.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace BotSharp.Core.A2A; + +public class A2APlugin : IBotSharpPlugin +{ + + public string Id => "058cdf87-fcf3-eda9-915a-565c04bc9f56"; + + public string Name => "A2A Protocol Integration"; + + public string Description => "Enables seamless integration with external agents via the Agent-to-Agent (A2A) protocol."; + + public void RegisterDI(IServiceCollection services, IConfiguration config) + { + services.AddScoped(provider => + { + var settingService = provider.GetRequiredService(); + return settingService.Bind("A2AIntegration"); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } +} diff --git a/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj b/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj new file mode 100644 index 000000000..c91cc0d54 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj @@ -0,0 +1,21 @@ + + + + $(TargetFramework) + $(LangVersion) + $(BotSharpVersion) + $(GeneratePackageOnBuild) + $(SolutionDir)packages + enable + enable + + + + + + + + + + + diff --git a/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs b/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs new file mode 100644 index 000000000..959267e57 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs @@ -0,0 +1,65 @@ +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Conversations.Models; +using BotSharp.Abstraction.Functions; +using BotSharp.Core.A2A.Services; +using BotSharp.Core.A2A.Settings; +using System.Text.Json; + +namespace BotSharp.Core.A2A.Functions; + +public class A2ADelegationFn : IFunctionCallback +{ + public string Name => "delegate_to_a2a"; + public string Indication => "Connecting to external agent network..."; + + private readonly IA2AService _a2aService; + private readonly A2ASettings _settings; + private readonly IConversationStateService _stateService; + + public A2ADelegationFn(IA2AService a2aService, A2ASettings settings, IConversationStateService stateService) + { + _a2aService = a2aService; + _settings = settings; + _stateService = stateService; + } + + public async Task Execute(RoleDialogModel message) + { + var args = JsonSerializer.Deserialize(message.FunctionArgs); + string queryText = string.Empty; + if (args.TryGetProperty("user_query", out var queryProp)) + { + queryText = queryProp.GetString(); + } + + var agentId = message.CurrentAgentId; + var agentConfig = _settings.Agents.FirstOrDefault(x => x.Id == agentId); + + if (agentConfig == null) + { + message.Content = "System Error: Remote agent configuration not found."; + message.StopCompletion = true; + return false; + } + + var conversationId = _stateService.GetConversationId(); + + try + { + var responseText = await _a2aService.SendMessageAsync( + agentConfig.Endpoint, + queryText, + conversationId, + CancellationToken.None + ); + + message.Content = responseText; + return true; + } + catch (Exception ex) + { + message.Content = $"Communication failure with external agent: {ex.Message}"; + return false; + } + } +} diff --git a/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs b/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs new file mode 100644 index 000000000..48aae9727 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs @@ -0,0 +1,85 @@ +using BotSharp.Abstraction.Agents; +using BotSharp.Abstraction.Agents.Enums; +using BotSharp.Abstraction.Agents.Models; +using BotSharp.Abstraction.Agents.Settings; +using BotSharp.Abstraction.Functions.Models; +using BotSharp.Core.A2A.Services; +using BotSharp.Core.A2A.Settings; +using System.Text.Json; + +namespace BotSharp.Core.A2A.Hooks; + +public class A2AAgentHook : AgentHookBase +{ + public override string SelfId => string.Empty; + + private readonly A2ASettings _settings; + private readonly IA2AService _iA2AService; + + public A2AAgentHook(IServiceProvider services, IA2AService a2AService, A2ASettings settings) + : base(services, new AgentSettings()) + { + _iA2AService = a2AService; + _settings = settings; + } + + public override bool OnAgentLoading(ref string id) + { + var agentId = id; + var remoteConfig = _settings.Agents.FirstOrDefault(x => x.Id == agentId); + if (remoteConfig != null) + { + return true; + } + return base.OnAgentLoading(ref id); + } + + public override void OnAgentLoaded(Agent agent) + { + // Check if this is an A2A remote agent + if (agent.Type != AgentType.A2ARemote) + { + return; + } + + var remoteConfig = _settings.Agents.FirstOrDefault(x => x.Id == agent.Id); + if (remoteConfig != null) + { + var agentCard = _iA2AService.GetCapabilitiesAsync(remoteConfig.Endpoint).GetAwaiter().GetResult(); + agent.Name = agentCard.Name; + agent.Description = agentCard.Description; + agent.Instruction = $"You are a proxy interface for an external intelligent service named '{agentCard.Name}'. " + + $"Your ONLY goal is to forward the user's request verbatim to the external service. " + + $"You must use the function 'delegate_to_a2a' to communicate with it. " + + $"Do not attempt to answer the question yourself."; + + var properties = new Dictionary + { + { + "user_query", + new + { + type = "string", + description = "The exact user request or task description to be forwarded." + } + } + }; + + var propertiesJson = JsonSerializer.Serialize(properties); + var propertiesDocument = JsonDocument.Parse(propertiesJson); + + agent.Functions.Add(new FunctionDef + { + Name = "delegate_to_a2a", + Description = $"Delegates the task to the external {remoteConfig.Name} via A2A protocol.", + Parameters = new FunctionParametersDef() + { + Type = "object", + Properties = propertiesDocument, + Required = new List { "user_query" } + } + }); + } + base.OnAgentLoaded(agent); + } +} diff --git a/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs b/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs new file mode 100644 index 000000000..7b654eba9 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs @@ -0,0 +1,158 @@ +using A2A; +using Microsoft.Extensions.Logging; +using System.Net.ServerSentEvents; +using System.Text.Json; + +namespace BotSharp.Core.A2A.Services; + +public class A2AService : IA2AService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly IServiceProvider _services; + + private readonly Dictionary _clientCache = new Dictionary(); + + public A2AService(IHttpClientFactory httpClientFactory, IServiceProvider serviceProvider, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _services = serviceProvider; + _logger = logger; + } + + public async Task GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default) + { + var resolver = new A2ACardResolver(new Uri(agentEndpoint)); + return await resolver.GetAgentCardAsync(); + } + + public async Task SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken) + { + + if (!_clientCache.TryGetValue(agentEndpoint, out var client)) + { + HttpClient httpclient = _httpClientFactory.CreateClient(); + + client = new A2AClient(new Uri(agentEndpoint), httpclient); + _clientCache[agentEndpoint] = client; + } + + var messagePayload = new AgentMessage + { + Role = MessageRole.User, + ContextId = contextId, + Parts = new List + { + new TextPart { Text = text } + } + }; + + var sendParams = new MessageSendParams + { + Message = messagePayload + }; + + try + { + _logger.LogInformation($"Sending A2A message to {agentEndpoint}. ContextId: {contextId}"); + var responseBase = await client.SendMessageAsync(sendParams, cancellationToken); + + if (responseBase is AgentMessage responseMsg) + { + if (responseMsg.Parts != null && responseMsg.Parts.Any()) + { + var textPart = responseMsg.Parts.First() as TextPart; + return textPart?.Text ?? string.Empty; + } + } + else if( responseBase is AgentTask atask) + { + return $"Task created with ID: {atask.Id}, Status: {atask.Status}"; + } + else + { + return "Unexpected task type."; + } + + return string.Empty; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, $"Network error communicating with A2A agent at {agentEndpoint}"); + throw new Exception($"Remote agent unavailable: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, $"A2A Protocol error: {ex.Message}"); + throw; + } + } + + public async Task SendMessageStreamingAsync(string endPoint, List parts, Func, Task>? onStreamingEventReceived, CancellationToken cancellationToken = default) + { + A2ACardResolver cardResolver = new(new Uri(endPoint)); + AgentCard agentCard = await cardResolver.GetAgentCardAsync(); + A2AClient client = new A2AClient(new Uri(agentCard.Url)); + + AgentMessage userMessage = new() + { + Role = MessageRole.User, + Parts = parts + }; + + await foreach (SseItem sseItem in client.SendMessageStreamingAsync(new MessageSendParams { Message = userMessage })) + { + await onStreamingEventReceived?.Invoke(sseItem); + } + + Console.WriteLine(" Streaming completed."); + } + + public async Task ListenForTaskEventAsync(string endPoint, string taskId, Func, ValueTask>? onTaskEventReceived = null, CancellationToken cancellationToken = default) + { + + if (onTaskEventReceived == null) + { + return; + } + + A2ACardResolver cardResolver = new(new Uri(endPoint)); + AgentCard agentCard = await cardResolver.GetAgentCardAsync(); + A2AClient client = new A2AClient(new Uri(agentCard.Url)); + + await foreach (SseItem sseItem in client.SubscribeToTaskAsync(taskId)) + { + await onTaskEventReceived.Invoke(sseItem); + Console.WriteLine(" Task event received: " + JsonSerializer.Serialize(sseItem.Data)); + } + + } + + public async Task SetPushNotifications(string endPoint, PushNotificationConfig config, CancellationToken cancellationToken = default) + { + A2ACardResolver cardResolver = new(new Uri(endPoint)); + AgentCard agentCard = await cardResolver.GetAgentCardAsync(); + A2AClient client = new A2AClient(new Uri(agentCard.Url)); + await client.SetPushNotificationAsync(new TaskPushNotificationConfig() + { + PushNotificationConfig = config + }); + } + + public async Task CancelTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default) + { + A2ACardResolver cardResolver = new(new Uri(endPoint)); + AgentCard agentCard = await cardResolver.GetAgentCardAsync(); + A2AClient client = new A2AClient(new Uri(agentCard.Url)); + return await client.CancelTaskAsync(taskId); + } + + public async Task GetTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default) + { + A2ACardResolver cardResolver = new(new Uri(endPoint)); + AgentCard agentCard = await cardResolver.GetAgentCardAsync(); + A2AClient client = new A2AClient(new Uri(agentCard.Url)); + return await client.GetTaskAsync(taskId); + } + +} diff --git a/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs b/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs new file mode 100644 index 000000000..47f921c2e --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs @@ -0,0 +1,26 @@ +using A2A; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.ServerSentEvents; +using System.Text; +using System.Threading.Tasks; + +namespace BotSharp.Core.A2A.Services; + +public interface IA2AService +{ + Task SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken = default); + + Task GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default); + + Task SendMessageStreamingAsync(string endPoint, List parts, Func, Task>? onStreamingEventReceived,CancellationToken cancellationToken = default); + + Task ListenForTaskEventAsync(string endPoint, string taskId, Func, ValueTask>? onTaskEventReceived = null, CancellationToken cancellationToken = default); + + Task SetPushNotifications(string endPoint, PushNotificationConfig config, CancellationToken cancellationToken = default); + + Task CancelTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken = default); + + Task GetTaskAsync(string endPoint, string taskId, CancellationToken cancellationToken); +} diff --git a/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs b/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs new file mode 100644 index 000000000..3aa9ec7a8 --- /dev/null +++ b/src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace BotSharp.Core.A2A.Settings; + +public class A2ASettings +{ + public bool Enabled { get; set; } + public int DefaultTimeoutSeconds { get; set; } = 30; + public List Agents { get; set; } = new List(); +} + +public class RemoteAgentConfig +{ + public string Id { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Endpoint { get; set; } + public List Capabilities { get; set; } +} diff --git a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs index 398f0c40d..4ac3f7314 100644 --- a/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs +++ b/src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs @@ -105,7 +105,7 @@ public RoutableAgent[] GetRoutableAgents(List profiles) }; var agents = db.GetAgents(filter).ConfigureAwait(false).GetAwaiter().GetResult(); - var routableAgents = agents.Where(x => x.Type == AgentType.Task || x.Type == AgentType.Planning).Select(x => new RoutableAgent + var routableAgents = agents.Where(x => x.Type == AgentType.Task || x.Type == AgentType.Planning || x.Type == AgentType.A2ARemote).Select(x => new RoutableAgent { AgentId = x.Id, Description = x.Description, diff --git a/src/WebStarter/WebStarter.csproj b/src/WebStarter/WebStarter.csproj index 07dba6efd..2c0478c5b 100644 --- a/src/WebStarter/WebStarter.csproj +++ b/src/WebStarter/WebStarter.csproj @@ -35,6 +35,7 @@ + diff --git a/src/WebStarter/appsettings.json b/src/WebStarter/appsettings.json index e0aca50d8..5d917523d 100644 --- a/src/WebStarter/appsettings.json +++ b/src/WebStarter/appsettings.json @@ -1006,10 +1006,22 @@ "Language": "en" } }, - + "A2AIntegration": { + "Enabled": true, + "DefaultTimeoutSeconds": 30, + "Agents": [ + { + "Id": "cdd9023f-a371-407a-43bf-f36ddccce340", + "Name": "SportKiosk", + "Description": "test", + "Endpoint": "http://localhost:5020/" + } + ] + }, "PluginLoader": { "Assemblies": [ "BotSharp.Core", + "BotSharp.Core.A2A", "BotSharp.Core.SideCar", "BotSharp.Core.Crontab", "BotSharp.Core.Realtime", diff --git a/tests/BotSharp.Plugin.PizzaBot/BotSharp.Plugin.PizzaBot.csproj b/tests/BotSharp.Plugin.PizzaBot/BotSharp.Plugin.PizzaBot.csproj index c056982df..bec357f4c 100644 --- a/tests/BotSharp.Plugin.PizzaBot/BotSharp.Plugin.PizzaBot.csproj +++ b/tests/BotSharp.Plugin.PizzaBot/BotSharp.Plugin.PizzaBot.csproj @@ -95,4 +95,10 @@ PreserveNewest + + + + PreserveNewest + + diff --git a/tests/BotSharp.Plugin.PizzaBot/PizzaBotPlugin.cs b/tests/BotSharp.Plugin.PizzaBot/PizzaBotPlugin.cs index 6fa37ce67..15345b56f 100644 --- a/tests/BotSharp.Plugin.PizzaBot/PizzaBotPlugin.cs +++ b/tests/BotSharp.Plugin.PizzaBot/PizzaBotPlugin.cs @@ -15,7 +15,8 @@ public class PizzaBotPlugin : IBotSharpPlugin "b284db86-e9c2-4c25-a59e-4649797dd130", "c2b57a74-ae4e-4c81-b3ad-9ac5bff982bd", "dfd9b46d-d00c-40af-8a75-3fbdc2b89869", - "fe8c60aa-b114-4ef3-93cb-a8efeac80f75" + "fe8c60aa-b114-4ef3-93cb-a8efeac80f75", + "cdd9023f-a371-407a-43bf-f36ddccce340" }; public void RegisterDI(IServiceCollection services, IConfiguration config) diff --git a/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json b/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json new file mode 100644 index 000000000..ce963da82 --- /dev/null +++ b/tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json @@ -0,0 +1,14 @@ +{ + "id": "cdd9023f-a371-407a-43bf-f36ddccce340", + "name": "SportKiosk", + "description": "Answers questions about sport events", + "type": "a2a-remote", + "disabled": false, + "isPublic": true, + "profiles": [ "pizza" ], + "labels": [ "experiment" ], + "llmConfig": { + "provider": "openai", + "model": "gpt-5-nano" + } +} \ No newline at end of file