Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
34 changes: 18 additions & 16 deletions src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager
{
rpcRequest = (JsonRpcRequest?)await JsonSerializer.DeserializeAsync(request.Body, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcRequest)), cancellationToken).ConfigureAwait(false);

activity?.AddTag("request.id", rpcRequest!.Id);
activity?.AddTag("request.id", rpcRequest!.Id.ToString());
activity?.AddTag("request.method", rpcRequest!.Method);

// Dispatch based on return type
Expand All @@ -51,15 +51,17 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager

return await SingleResponseAsync(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false);
}
catch (A2AException ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(rpcRequest?.Id ?? ex.GetRequestId(), ex));
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(rpcRequest?.Id, ex.Message));
catch (A2AException ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
var errorId = rpcRequest?.Id ?? new JsonRpcId(ex.GetRequestId());
return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(errorId, ex));
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
var errorId = rpcRequest?.Id ?? new JsonRpcId((string?)null);
return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, ex.Message));
}
}

Expand All @@ -76,10 +78,10 @@ internal static async Task<IResult> ProcessRequestAsync(ITaskManager taskManager
/// <param name="parameters">The JSON parameters for the method call.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A JSON-RPC response result containing the operation result or error.</returns>
internal static async Task<JsonRpcResponseResult> SingleResponseAsync(ITaskManager taskManager, string? requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
internal static async Task<JsonRpcResponseResult> SingleResponseAsync(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
activity?.SetTag("request.id", requestId);
using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());
activity?.SetTag("request.method", method);

JsonRpcResponse? response = null;
Expand Down Expand Up @@ -165,10 +167,10 @@ private static T DeserializeAndValidate<T>(JsonElement jsonParamValue) where T :
/// <param name="parameters">The JSON parameters for the streaming method call.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
/// <returns>An HTTP result that streams JSON-RPC responses as Server-Sent Events or an error response.</returns>
internal static IResult StreamResponse(ITaskManager taskManager, string? requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server);
activity?.SetTag("request.id", requestId);
using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());

if (parameters == null)
{
Expand Down
4 changes: 2 additions & 2 deletions src/A2A.AspNetCore/JsonRpcStreamedResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ namespace A2A.AspNetCore;
public class JsonRpcStreamedResult : IResult
{
private readonly IAsyncEnumerable<A2AEvent> _events;
private readonly string? requestId;
private readonly JsonRpcId requestId;

/// <summary>
/// Initializes a new instance of the JsonRpcStreamedResult class.
/// </summary>
/// <param name="events">The async enumerable stream of A2A events to send as Server-Sent Events.</param>
/// <param name="requestId">The JSON-RPC request ID used for correlating responses with the original request.</param>
public JsonRpcStreamedResult(IAsyncEnumerable<A2AEvent> events, string? requestId)
public JsonRpcStreamedResult(IAsyncEnumerable<A2AEvent> events, JsonRpcId requestId)
{
ArgumentNullException.ThrowIfNull(events);

Expand Down
3 changes: 2 additions & 1 deletion src/A2A/A2A.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<!-- Dependencies only needed by netstandard2.0 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.Threading.Channels" />
</ItemGroup>

<!-- Dependencies needed by all -->
Expand All @@ -36,7 +37,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="System.Threading.Channels" />

</ItemGroup>

<ItemGroup>
Expand Down
25 changes: 24 additions & 1 deletion src/A2A/A2AExceptionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,35 @@ public static A2AException WithRequestId(this A2AException exception, string? re
return exception;
}

/// <summary>
/// Associates a request ID with the specified <see cref="A2AException"/>.
/// </summary>
/// <param name="exception">The <see cref="A2AException"/> to associate the request ID with.</param>
/// <param name="requestId">The request ID to associate with the exception.</param>
/// <returns>The same <see cref="A2AException"/> instance with the request ID stored in its Data collection.</returns>
/// <remarks>
/// This method stores the request ID in the exception's Data collection using the key "RequestId".
/// The request ID can be later retrieved using the <see cref="GetRequestId"/> method.
/// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios.
/// </remarks>
public static A2AException WithRequestId(this A2AException exception, JsonRpcId requestId)
{
if (exception is null)
{
throw new ArgumentNullException(nameof(exception));
}

exception.Data[RequestIdKey] = requestId.ToString();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if the conversion to string is necessary. Can't we just stick the requestId as is into the data dictionary without conversion? Then, new JsonRpcId(ex.GetRequestId()) won't be necessary.


return exception;
}

/// <summary>
/// Retrieves the request ID associated with the specified <see cref="A2AException"/>.
/// </summary>
/// <param name="exception">The <see cref="A2AException"/> to retrieve the request ID from.</param>
/// <returns>
/// The request ID associated with the exception if one was previously set using <see cref="WithRequestId"/>,
/// The request ID associated with the exception if one was previously set using <see cref="WithRequestId(A2AException, string?)"/>,
/// or null if no request ID was set or if the stored value is not a string.
/// </returns>
/// <remarks>
Expand Down
1 change: 1 addition & 0 deletions src/A2A/A2AJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public static partial class A2AJsonUtilities

// JSON-RPC
[JsonSerializable(typeof(JsonRpcError))]
[JsonSerializable(typeof(JsonRpcId))]
[JsonSerializable(typeof(JsonRpcRequest))]
[JsonSerializable(typeof(JsonRpcResponse))]
[JsonSerializable(typeof(Dictionary<string, JsonElement>))]
Expand Down
9 changes: 7 additions & 2 deletions src/A2A/Client/A2AClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ private async IAsyncEnumerable<SseItem<TOutput>> SendRpcSseRequestAsync<TInput,
"text/event-stream",
cancellationToken).ConfigureAwait(false);

var sseParser = SseParser.Create(responseStream, (eventType, data) =>
var sseParser = SseParser.Create(responseStream, (_, data) =>
{
var reader = new Utf8JsonReader(data);

Expand All @@ -146,7 +146,12 @@ private async IAsyncEnumerable<SseItem<TOutput>> SendRpcSseRequestAsync<TInput,
throw new A2AException(error.Message, (A2AErrorCode)error.Code);
}

return JsonSerializer.Deserialize(responseObject?.Result, outputTypeInfo) ??
if (responseObject?.Result is null)
{
throw new InvalidOperationException("Failed to deserialize the event: Result is null.");
}

return responseObject.Result.Deserialize(outputTypeInfo) ??
throw new InvalidOperationException("Failed to deserialize the event.");
});

Expand Down
170 changes: 170 additions & 0 deletions src/A2A/JsonRpc/JsonRpcId.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Serialization;

namespace A2A;

/// <summary>
/// Represents a JSON-RPC ID that can be either a string or a number, preserving the original type.
/// </summary>
[JsonConverter(typeof(Converter))]
public readonly struct JsonRpcId : IEquatable<JsonRpcId>
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a string value.
/// </summary>
/// <param name="value">The string value.</param>
public JsonRpcId(string? value) => Value = value;

/// <summary>
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a numeric value.
/// </summary>
/// <param name="value">The numeric value.</param>
public JsonRpcId(long value) => Value = value;

/// <summary>
/// Initializes a new instance of the <see cref="JsonRpcId"/> struct with a numeric value.
/// </summary>
/// <param name="value">The numeric value.</param>
public JsonRpcId(int value) => Value = (long)value;

/// <summary>
/// Gets a value indicating whether this ID has a value.
/// </summary>
public bool HasValue => Value is not null;

/// <summary>
/// Gets a value indicating whether this ID is a string.
/// </summary>
public bool IsString => Value is string;

/// <summary>
/// Gets a value indicating whether this ID is a number.
/// </summary>
public bool IsNumber => Value is long;

/// <summary>
/// Gets the string value of this ID if it's a string.
/// </summary>
/// <returns>The string value, or null if not a string.</returns>
public string? AsString() => Value as string;

/// <summary>
/// Gets the numeric value of this ID if it's a number.
/// </summary>
/// <returns>The numeric value, or null if not a number.</returns>
public long? AsNumber() => Value as long?;

/// <summary>
/// Gets the raw value as an object.
/// </summary>
/// <returns>The raw value as an object.</returns>
public object? Value { get; }

/// <summary>
/// Returns a string representation of this ID.
/// </summary>
/// <returns>A string representation of the ID.</returns>
public override string? ToString() => Value?.ToString();

/// <summary>
/// Determines whether the specified object is equal to the current ID.
/// </summary>
/// <param name="obj">The object to compare with the current ID.</param>
/// <returns>true if the specified object is equal to the current ID; otherwise, false.</returns>
public override bool Equals(object? obj) => obj is JsonRpcId other && Equals(other);

/// <summary>
/// Determines whether the specified ID is equal to the current ID.
/// </summary>
/// <param name="other">The ID to compare with the current ID.</param>
/// <returns>true if the specified ID is equal to the current ID; otherwise, false.</returns>
public bool Equals(JsonRpcId other) => Equals(Value, other.Value);

/// <summary>
/// Returns the hash code for this ID.
/// </summary>
/// <returns>A hash code for the current ID.</returns>
public override int GetHashCode() => Value?.GetHashCode() ?? 0;

/// <summary>
/// Determines whether two IDs are equal.
/// </summary>
/// <param name="left">The first ID to compare.</param>
/// <param name="right">The second ID to compare.</param>
/// <returns>true if the IDs are equal; otherwise, false.</returns>
public static bool operator ==(JsonRpcId left, JsonRpcId right) => left.Equals(right);

/// <summary>
/// Determines whether two IDs are not equal.
/// </summary>
/// <param name="left">The first ID to compare.</param>
/// <param name="right">The second ID to compare.</param>
/// <returns>true if the IDs are not equal; otherwise, false.</returns>
public static bool operator !=(JsonRpcId left, JsonRpcId right) => !left.Equals(right);

/// <summary>
/// Implicitly converts a string to a JsonRpcId.
/// </summary>
/// <param name="value">The string value.</param>
/// <returns>A JsonRpcId with the string value.</returns>
public static implicit operator JsonRpcId(string? value) => new(value);

/// <summary>
/// Implicitly converts a long to a JsonRpcId.
/// </summary>
/// <param name="value">The numeric value.</param>
/// <returns>A JsonRpcId with the numeric value.</returns>
public static implicit operator JsonRpcId(long value) => new(value);

/// <summary>
/// Implicitly converts an int to a JsonRpcId.
/// </summary>
/// <param name="value">The numeric value.</param>
/// <returns>A JsonRpcId with the numeric value.</returns>
public static implicit operator JsonRpcId(int value) => new(value);

/// <summary>
/// JSON converter for JsonRpcId that preserves the original type during serialization/deserialization.
/// </summary>
internal sealed class Converter : JsonConverter<JsonRpcId>
{
public override JsonRpcId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.String:
return new JsonRpcId(reader.GetString());
case JsonTokenType.Number:
if (reader.TryGetInt64(out var longValue))
{
return new JsonRpcId(longValue);
}
throw new JsonException("Invalid numeric value for JSON-RPC ID.");
case JsonTokenType.Null:
return new JsonRpcId(null);
default:
throw new JsonException("Invalid JSON-RPC ID format. Must be string, number, or null.");
}
}

public override void Write(Utf8JsonWriter writer, JsonRpcId value, JsonSerializerOptions options)
{
if (!value.HasValue)
{
writer.WriteNullValue();
}
else if (value.IsString)
{
writer.WriteStringValue(value.AsString());
}
else if (value.IsNumber)
{
writer.WriteNumberValue(value.AsNumber()!.Value);
}
else
{
writer.WriteNullValue();
}
}
}
}
2 changes: 1 addition & 1 deletion src/A2A/JsonRpc/JsonRpcRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public sealed class JsonRpcRequest
/// Numbers SHOULD NOT contain fractional parts.
/// </remarks>
[JsonPropertyName("id")]
public string? Id { get; set; }
public JsonRpcId Id { get; set; }

/// <summary>
/// Gets or sets the string containing the name of the method to be invoked.
Expand Down
Loading