diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index bf589d7fe0..b60878adff 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -499,7 +499,7 @@ We need to decide what AIContent types, each agent response type will be mapped | AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent.Agent.structured_output) | | LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response | | Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/examples/getting-started/structured-output) at agent construction time | -| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2aproject.github.io/A2A/v0.2.5/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | +| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time | | Protocol Activity | Supports returning [Complex types](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md#complex-types) but no support for requesting a type | ### Response Reason Support @@ -511,5 +511,5 @@ We need to decide what AIContent types, each agent response type will be mapped | AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/api-reference/types/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/api-reference/agent/#strands.agent.agent_result.AgentResult) class with options that are tied closely to LLM operations. | | LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | | Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) | -| A2A | No equivalent present, response only contains a [message](https://a2aproject.github.io/A2A/v0.2.5/specification/#64-message-object) or [task](https://a2aproject.github.io/A2A/v0.2.5/specification/#61-task-object). | +| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). | | Protocol Activity | [No equivalent present.](https://github.com/microsoft/Agents/blob/main/specs/activity/protocol-activity.md) | diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 400b35d298..bba35b5703 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -21,6 +21,10 @@ + + + + diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj new file mode 100644 index 0000000000..341798a38e --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj @@ -0,0 +1,25 @@ + + + + Exe + net9.0 + + enable + enable + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs new file mode 100644 index 0000000000..f0b138c2c7 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/Program.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to represent an A2A agent as a set of function tools, where each function tool +// corresponds to a skill of the A2A agent, and register these function tools with another AI agent so +// it can leverage the A2A agent's skills. + +using System.Text.RegularExpressions; +using A2A; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent a2aAgent = await agentCard.GetAIAgentAsync(); + +// Create the main agent, and provide the a2a agent skills as a function tools. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new AzureCliCredential()) + .GetChatClient(deploymentName) + .CreateAIAgent( + instructions: "You are a helpful assistant that helps people with travel planning.", + tools: [.. CreateFunctionTools(a2aAgent, agentCard)] + ); + +// Invoke the agent and output the text result. +Console.WriteLine(await agent.RunAsync("Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls")); + +static IEnumerable CreateFunctionTools(AIAgent a2aAgent, AgentCard agentCard) +{ + foreach (var skill in agentCard.Skills) + { + // A2A agent skills don't have schemas describing the expected shape of their inputs and outputs. + // Schemas can be beneficial for AI models to better understand the skill's contract, generate + // the skill's input accordingly and to know what to expect in the skill's output. + // However, the A2A specification defines properties such as name, description, tags, examples, + // inputModes, and outputModes to provide context about the skill's purpose, capabilities, usage, + // and supported MIME types. These properties are added to the function tool description to help + // the model determine the appropriate shape of the skill's input and output. + AIFunctionFactoryOptions options = new() + { + Name = FunctionNameSanitizer.Sanitize(skill.Name), + Description = $$""" + { + "description": "{{skill.Description}}", + "tags": "[{{string.Join(", ", skill.Tags ?? [])}}]", + "examples": "[{{string.Join(", ", skill.Examples ?? [])}}]", + "inputModes": "[{{string.Join(", ", skill.InputModes ?? [])}}]", + "outputModes": "[{{string.Join(", ", skill.OutputModes ?? [])}}]" + } + """, + }; + + yield return AIFunctionFactory.Create(RunAgentAsync, options); + } + + async Task RunAgentAsync(string input, CancellationToken cancellationToken) + { + var response = await a2aAgent.RunAsync(input, cancellationToken: cancellationToken).ConfigureAwait(false); + + return response.Text; + } +} + +internal static partial class FunctionNameSanitizer +{ + public static string Sanitize(string name) + { + return InvalidNameCharsRegex().Replace(name, "_"); + } + + [GeneratedRegex("[^0-9A-Za-z]+")] + private static partial Regex InvalidNameCharsRegex(); +} diff --git a/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md new file mode 100644 index 0000000000..6cbd56dca4 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/A2AAgent_AsFunctionTools/README.md @@ -0,0 +1,22 @@ +# A2A Agent as Function Tools + +This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, +and register these function tools with another AI agent so it can leverage the A2A agent's skills. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 8.0 SDK or later +- Access to the A2A agent host service + +**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be +spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md + +Set the following environment variables: + +```powershell +$env:A2A_AGENT_HOST="https://your-a2a-agent-host" # Replace with your A2A agent host endpoint +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` \ No newline at end of file diff --git a/dotnet/samples/GettingStarted/A2A/README.md b/dotnet/samples/GettingStarted/A2A/README.md new file mode 100644 index 0000000000..3ddac95996 --- /dev/null +++ b/dotnet/samples/GettingStarted/A2A/README.md @@ -0,0 +1,50 @@ +# Agent-to-Agent (A2A) Samples + +These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework. + +For other samples that demonstrate how to use AIAgent instances, +see the [Getting Started With Agents](../Agents/README.md) samples. + +## Prerequisites + +See the README.md for each sample for the prerequisites for that sample. + +## Samples + +|Sample|Description| +|---|---| +|[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| + +## Running the samples from the console + +To run the samples, navigate to the desired sample directory, e.g. + +```powershell +cd A2AAgent_AsFunctionTools +``` + +Set the required environment variables as documented in the sample readme. +If the variables are not set, you will be prompted for the values when running the samples. +Execute the following command to build the sample: + +```powershell +dotnet build +``` + +Execute the following command to run the sample: + +```powershell +dotnet run --no-build +``` + +Or just build and run in one step: + +```powershell +dotnet run +``` + +## Running the samples from Visual Studio + +Open the solution in Visual Studio and set the desired sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`. + +You will be prompted for any required environment variables if they are not already set. diff --git a/dotnet/samples/GettingStarted/README.md b/dotnet/samples/GettingStarted/README.md index 82bb0299d0..e7249ac33d 100644 --- a/dotnet/samples/GettingStarted/README.md +++ b/dotnet/samples/GettingStarted/README.md @@ -9,6 +9,7 @@ of the agent framework. |---|---| |[Agents](./Agents/README.md)|Step by step instructions for getting started with agents| |[Agent Providers](./AgentProviders/README.md)|Getting started with creating agents using various providers| +|[A2A](./A2A/README.md)|Getting started with A2A (Agent-to-Agent) specific features| |[Agent Open Telemetry](./AgentOpenTelemetry/README.md)|Getting started with OpenTelemetry for agents| |[Agent With OpenAI exchange types](./AgentWithOpenAI/README.md)|Using OpenAI exchange types with agents| |[Workflow](./Workflows/README.md)|Getting started with Workflow| diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs new file mode 100644 index 0000000000..3c59abcef0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Logging; + +namespace A2A; + +/// +/// Provides extension methods for to simplify the creation of A2A agents. +/// +/// +/// These extensions bridge the gap between A2A SDK client and . +/// +public static class A2AAgentCardExtensions +{ + /// + /// Retrieves an instance of for an existing A2A agent. + /// + /// + /// This method can be used to access A2A agents that support the + /// Curated Registries (Catalog-Based Discovery) + /// discovery mechanism. + /// + /// The to use for the agent creation. + /// The to use for HTTP requests. + /// The logger factory for enabling logging within the agent. + /// An instance backed by the A2A agent. + public static async Task GetAIAgentAsync(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) + { + // Create the A2A client using the agent URL from the card. + var a2aClient = new A2AClient(new Uri(card.Url), httpClient); + + return a2aClient.GetAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs index d9fe2a9a1c..f458f74a1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -29,11 +28,9 @@ public static class A2ACardResolverExtensions /// Retrieves an instance of for an existing A2A agent. /// /// - /// Creates an AI agent for an A2A agent whose host supports one of the A2A discovery mechanisms: - /// - /// Well-Known URI - /// Curated Registries (Catalog-Based Discovery) - /// + /// This method can be used to access A2A agents that support the + /// Well-Known URI + /// discovery mechanism. /// /// The to use for the agent creation. /// The to use for HTTP requests. @@ -45,9 +42,6 @@ public static class A2ACardResolverExtensions // Obtain the agent card from the resolver. var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false); - // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(agentCard.Url), httpClient); - - return a2aClient.GetAIAgent(name: agentCard.Name, description: agentCard.Description, loggerFactory: loggerFactory); + return await agentCard.GetAIAgentAsync(httpClient, loggerFactory).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs index 300bafbb90..095481c0d4 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs @@ -25,7 +25,7 @@ public static class A2AClientExtensions /// Retrieves an instance of for an existing A2A agent. /// /// - /// This method can be used to create AI agents for A2A agents whose hosts support the + /// This method can be used to access A2A agents that support the /// Direct Configuration / Private Discovery /// discovery mechanism. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs new file mode 100644 index 0000000000..48d1c699e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; + +namespace Microsoft.Agents.AI.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentCardExtensionsTests +{ + private readonly AgentCard _agentCard; + + public A2AAgentCardExtensionsTests() + { + this._agentCard = new AgentCard + { + Name = "Test Agent", + Description = "A test agent for unit testing", + Url = "http://test-endpoint/agent" + }; + } + + [Fact] + public async Task GetAIAgentAsync_ReturnsAIAgentAsync() + { + // Act + var agent = await this._agentCard.GetAIAgentAsync(); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Test Agent", agent.Name); + Assert.Equal("A test agent for unit testing", agent.Description); + } + + [Fact] + public async Task RunIAgentAsync_SendsRequestToTheUrlSpecifiedInAgentCardAsync() + { + // Arrange + using var handler = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(handler, false); + + handler.ResponsesToReturn.Enqueue(new AgentMessage + { + Role = MessageRole.Agent, + Parts = [new TextPart { Text = "Response" }], + }); + + var agent = await this._agentCard.GetAIAgentAsync(httpClient); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Single(handler.CapturedUris); + Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]); + } + + internal sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public Queue ResponsesToReturn { get; } = new(); + + public List CapturedUris { get; } = []; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.CapturedUris.Add(request.RequestUri!); + + var response = this.ResponsesToReturn.Dequeue(); + + if (response is AgentCard agentCard) + { + var json = JsonSerializer.Serialize(agentCard); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + else if (response is AgentMessage message) + { + var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + + // Return empty agent card if none specified + var emptyCard = new AgentCard(); + var emptyJson = JsonSerializer.Serialize(emptyCard); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(emptyJson, Encoding.UTF8, "application/json") + }; + } + } +}