Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions A2A.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<Project Path="src/A2A.AspNetCore/A2A.AspNetCore.csproj" />
<Project Path="src/A2A/A2A.csproj" />
<Project Path="src/A2A.V0_3/A2A.V0_3.csproj" />
<Project Path="src/A2A.V0_3Compat/A2A.V0_3Compat.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
Expand All @@ -33,5 +34,6 @@
<Project Path="tests/A2A.AspNetCore.UnitTests/A2A.AspNetCore.UnitTests.csproj" />
<Project Path="tests/A2A.UnitTests/A2A.UnitTests.csproj" />
<Project Path="tests/A2A.V0_3.UnitTests/A2A.V0_3.UnitTests.csproj" />
<Project Path="tests/A2A.V0_3Compat.UnitTests/A2A.V0_3Compat.UnitTests.csproj" />
</Folder>
</Solution>
40 changes: 36 additions & 4 deletions src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ namespace A2A.AspNetCore;
/// </summary>
public static class A2AJsonRpcProcessor
{
internal static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken)
/// <summary>
/// Validates protocol-level preconditions on the incoming HTTP request before body parsing.
/// Returns a non-null <see cref="IResult"/> error response if the request should be rejected,
/// or <c>null</c> if the request may proceed.
/// Call this at the top of any request processor that wraps or extends this class.
/// </summary>
/// <param name="request">The incoming HTTP request.</param>
public static IResult? CheckPreflight(HttpRequest request)
{
// Version negotiation: check A2A-Version header
ArgumentNullException.ThrowIfNull(request);
var version = request.Headers["A2A-Version"].FirstOrDefault();
if (!string.IsNullOrEmpty(version) && version != "1.0" && version != "0.3")
{
Expand All @@ -22,6 +29,13 @@ internal static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler reque
$"Protocol version '{version}' is not supported. Supported versions: 0.3, 1.0",
A2AErrorCode.VersionNotSupported)));
}
return null;
}

internal static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken)
{
var preflightResult = CheckPreflight(request);
if (preflightResult != null) return preflightResult;

using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("HandleA2ARequest", ActivityKind.Server);

Expand Down Expand Up @@ -55,7 +69,16 @@ internal static async Task<IResult> ProcessRequestAsync(IA2ARequestHandler reque
}
}

internal static async Task<JsonRpcResponseResult> SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
/// <summary>
/// Handles a single (non-streaming) JSON-RPC request with a v1.0 method name and parameters.
/// </summary>
/// <param name="requestHandler">The v1.0 A2A request handler.</param>
/// <param name="requestId">The JSON-RPC request ID.</param>
/// <param name="method">The JSON-RPC method name.</param>
/// <param name="parameters">The JSON-RPC parameters element.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <see cref="JsonRpcResponseResult"/> containing the response.</returns>
public static async Task<JsonRpcResponseResult> SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());
Expand Down Expand Up @@ -186,7 +209,16 @@ private static T DeserializeAndValidate<T>(JsonElement jsonParamValue) where T :
return parms;
}

internal static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
/// <summary>
/// Handles a streaming JSON-RPC request with a v1.0 method name and parameters.
/// </summary>
/// <param name="requestHandler">The v1.0 A2A request handler.</param>
/// <param name="requestId">The JSON-RPC request ID.</param>
/// <param name="method">The JSON-RPC method name.</param>
/// <param name="parameters">The JSON-RPC parameters element.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An <see cref="IResult"/> that streams the response events.</returns>
public static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("StreamResponse", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());
Expand Down
34 changes: 34 additions & 0 deletions src/A2A.V0_3Compat/A2A.V0_3Compat.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net8.0</TargetFrameworks>
<IsAotCompatible>true</IsAotCompatible>
<PackageId>A2A.V0_3Compat</PackageId>
<Description>Version compatibility layer providing automatic fallback from A2A v1.0 to v0.3 servers.</Description>
<PackageTags>Agent2Agent;a2a;agent;ai;llm;compat</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="A2A.V0_3Compat.UnitTests" Key="0024000004800000940000000602000000240000525341310004000001000100fdff21bdfb01242cffd857fcfe4fd1048248b80c2d5779adf1916ba0f2fdfb7d9f780ba2c7eb359d8b2c40be090f54d99f29ada7769f3b50d5db1e92e645577abc702cb53a9ccdae1ff5aaf4c413f9ba4fd26f298f8756d38d0c4c9c813b39dd6f760f29ed0094f55af0dd698df03c714dace31a70362a2970fd0fa5a5dc5ec1" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\A2A\A2A.csproj" />
<ProjectReference Include="..\A2A.AspNetCore\A2A.AspNetCore.csproj" />
<ProjectReference Include="..\A2A.V0_3\A2A.V0_3.csproj" />

<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ExtraAnalyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>
51 changes: 51 additions & 0 deletions src/A2A.V0_3Compat/A2AClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace A2A.V0_3Compat;

using System.Text.Json;

using V03 = A2A.V0_3;

/// <summary>
/// Registers a v0.3 compatibility fallback with <see cref="A2AClientFactory"/>.
/// Once registered, the factory automatically creates a v0.3 adapter for agents
/// whose agent card does not contain <c>supportedInterfaces</c>.
/// </summary>
public static class V03FallbackRegistration
{
/// <summary>
/// Registers the v0.3 fallback globally with <see cref="A2AClientFactory"/>.
/// Call this once at application startup.
/// </summary>
public static void Register()
{
A2AClientFactory.RegisterFallback(CreateV03Client);
}

/// <summary>
/// Creates an <see cref="IA2AClient"/> from a v0.3-shaped agent card JSON string.
/// Can be used directly as a <see cref="A2AClientOptions.FallbackFactory"/> delegate
/// or via <see cref="Register"/> for global registration.
/// </summary>
/// <param name="agentCardJson">The raw JSON string of the agent card.</param>
/// <param name="baseUrl">The base URL of the agent, used as fallback when the card does not specify a URL.</param>
/// <param name="httpClient">Optional HTTP client to use for requests.</param>
/// <returns>An <see cref="IA2AClient"/> wrapping a v0.3 client.</returns>
public static IA2AClient CreateV03Client(string agentCardJson, Uri baseUrl, HttpClient? httpClient)
{
using var doc = JsonDocument.Parse(agentCardJson);
var root = doc.RootElement;

string url;
if (root.TryGetProperty("url", out var urlProp) &&
urlProp.ValueKind == JsonValueKind.String)
{
url = urlProp.GetString()!;
}
else
{
url = baseUrl.ToString();
}

var v03Client = new V03.A2AClient(new Uri(url), httpClient);
return new V03ClientAdapter(v03Client);
}
}
139 changes: 139 additions & 0 deletions src/A2A.V0_3Compat/V03ClientAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
namespace A2A.V0_3Compat;

using System.Runtime.CompilerServices;

using V03 = A2A.V0_3;

/// <summary>Adapts a v0.3 A2A client to the v1.0 <see cref="A2A.IA2AClient"/> interface.</summary>
internal sealed class V03ClientAdapter : A2A.IA2AClient, IDisposable
{
private readonly V03.A2AClient _v03Client;

/// <summary>Initializes a new instance of the <see cref="V03ClientAdapter"/> class.</summary>
/// <param name="v03Client">The v0.3 client to wrap.</param>
internal V03ClientAdapter(V03.A2AClient v03Client)
{
ArgumentNullException.ThrowIfNull(v03Client);
_v03Client = v03Client;
}

/// <inheritdoc />
public async Task<A2A.SendMessageResponse> SendMessageAsync(
A2A.SendMessageRequest request,
CancellationToken cancellationToken = default)
{
var v03Params = V03TypeConverter.ToV03(request);
var v03Response = await _v03Client.SendMessageAsync(v03Params, cancellationToken).ConfigureAwait(false);
return V03TypeConverter.ToV1Response(v03Response);
}

/// <inheritdoc />
public async IAsyncEnumerable<A2A.StreamResponse> SendStreamingMessageAsync(
A2A.SendMessageRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var v03Params = V03TypeConverter.ToV03(request);
await foreach (var sseItem in _v03Client.SendMessageStreamingAsync(v03Params, cancellationToken).ConfigureAwait(false))
{
if (sseItem.Data is { } evt)
{
yield return V03TypeConverter.ToV1StreamResponse(evt);
}
}
}

/// <inheritdoc />
public async Task<A2A.AgentTask> GetTaskAsync(
A2A.GetTaskRequest request,
CancellationToken cancellationToken = default)
{
var v03Task = await _v03Client.GetTaskAsync(request.Id, cancellationToken).ConfigureAwait(false);
return V03TypeConverter.ToV1Task(v03Task);
}

/// <inheritdoc />
public Task<A2A.ListTasksResponse> ListTasksAsync(
A2A.ListTasksRequest request,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("v0.3 does not support listing tasks.");

/// <inheritdoc />
public async Task<A2A.AgentTask> CancelTaskAsync(
A2A.CancelTaskRequest request,
CancellationToken cancellationToken = default)
{
var v03Params = new V03.TaskIdParams
{
Id = request.Id,
Metadata = request.Metadata,
};
var v03Task = await _v03Client.CancelTaskAsync(v03Params, cancellationToken).ConfigureAwait(false);
return V03TypeConverter.ToV1Task(v03Task);
}

/// <inheritdoc />
public async IAsyncEnumerable<A2A.StreamResponse> SubscribeToTaskAsync(
A2A.SubscribeToTaskRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var sseItem in _v03Client.SubscribeToTaskAsync(request.Id, cancellationToken).ConfigureAwait(false))
{
if (sseItem.Data is { } evt)
{
yield return V03TypeConverter.ToV1StreamResponse(evt);
}
}
}

/// <inheritdoc />
public async Task<A2A.TaskPushNotificationConfig> CreateTaskPushNotificationConfigAsync(
A2A.CreateTaskPushNotificationConfigRequest request,
CancellationToken cancellationToken = default)
{
var v03Config = new V03.TaskPushNotificationConfig
{
TaskId = request.TaskId,
PushNotificationConfig = V03TypeConverter.ToV03PushNotificationConfig(request.Config),
};
var v03Result = await _v03Client.SetPushNotificationAsync(v03Config, cancellationToken).ConfigureAwait(false);
return V03TypeConverter.ToV1TaskPushNotificationConfig(v03Result);
}

/// <inheritdoc />
public async Task<A2A.TaskPushNotificationConfig> GetTaskPushNotificationConfigAsync(
A2A.GetTaskPushNotificationConfigRequest request,
CancellationToken cancellationToken = default)
{
var v03Params = new V03.GetTaskPushNotificationConfigParams
{
Id = request.TaskId,
PushNotificationConfigId = request.Id,
};
var v03Result = await _v03Client.GetPushNotificationAsync(v03Params, cancellationToken).ConfigureAwait(false);
return V03TypeConverter.ToV1TaskPushNotificationConfig(v03Result);
}

/// <inheritdoc />
public Task<A2A.ListTaskPushNotificationConfigResponse> ListTaskPushNotificationConfigAsync(
A2A.ListTaskPushNotificationConfigRequest request,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("v0.3 does not support listing push notification configs.");

/// <inheritdoc />
public Task DeleteTaskPushNotificationConfigAsync(
A2A.DeleteTaskPushNotificationConfigRequest request,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("v0.3 does not support deleting push notification configs.");

/// <inheritdoc />
public Task<A2A.AgentCard> GetExtendedAgentCardAsync(
A2A.GetExtendedAgentCardRequest request,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException("v0.3 does not support extended agent cards.");

/// <inheritdoc />
public void Dispose()
{
// The v0.3 A2AClient does not implement IDisposable, so nothing to dispose.
}
}
33 changes: 33 additions & 0 deletions src/A2A.V0_3Compat/V03JsonRpcResponseResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace A2A.V0_3Compat;

using Microsoft.AspNetCore.Http;
using System.Text.Json;

using V03 = A2A.V0_3;

/// <summary>Result type for returning v0.3-format JSON-RPC responses as JSON in HTTP responses.</summary>
internal sealed class V03JsonRpcResponseResult : IResult
{
private readonly V03.JsonRpcResponse _response;

internal V03JsonRpcResponseResult(V03.JsonRpcResponse response)
{
ArgumentNullException.ThrowIfNull(response);
_response = response;
}

/// <inheritdoc />
public async Task ExecuteAsync(HttpContext httpContext)
{
ArgumentNullException.ThrowIfNull(httpContext);

httpContext.Response.ContentType = "application/json";
httpContext.Response.StatusCode = StatusCodes.Status200OK;

await JsonSerializer.SerializeAsync(
httpContext.Response.Body,
_response,
V03.A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(V03.JsonRpcResponse)),
httpContext.RequestAborted).ConfigureAwait(false);
}
}
Loading