diff --git a/.gitignore b/.gitignore
index 3c230a01..b330f959 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,6 +47,9 @@ ScaffoldingReadMe.txt
*~
CodeCoverage/
+# Copilot tracking artifacts
+.copilot-tracking/
+
# MSBuild Binary and Structured Log
*.binlog
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..fd8d7ba2
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,49 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "FileStoreDemo",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "${workspaceFolder}/samples/FileStoreDemo/bin/Debug/net10.0/FileStoreDemo.dll",
+ "args": [],
+ "cwd": "${workspaceFolder}/samples/FileStoreDemo",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "preLaunchTask": "build-filestoreDemo"
+ },
+ {
+ "name": "AgentServer (EchoTasks)",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll",
+ "args": ["--agent", "echotasks"],
+ "cwd": "${workspaceFolder}/samples/AgentServer",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "preLaunchTask": "build-agentServer"
+ },
+ {
+ "name": "AgentServer (SpecCompliance)",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll",
+ "args": ["--agent", "speccompliance"],
+ "cwd": "${workspaceFolder}/samples/AgentServer",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "preLaunchTask": "build-agentServer"
+ },
+ {
+ "name": "AgentServer (FileStore)",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "${workspaceFolder}/samples/AgentServer/bin/Debug/net10.0/AgentServer.dll",
+ "args": ["--agent", "echotasks", "--store", "file", "--data-dir", "${workspaceFolder}/samples/AgentServer/a2a-data"],
+ "cwd": "${workspaceFolder}/samples/AgentServer",
+ "console": "integratedTerminal",
+ "stopAtEntry": false,
+ "preLaunchTask": "build-agentServer"
+ }
+ ]
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 00000000..2cbc89b0
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,21 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "label": "build-filestoreDemo",
+ "type": "shell",
+ "command": "dotnet",
+ "args": ["build", "${workspaceFolder}/samples/FileStoreDemo/FileStoreDemo.csproj"],
+ "group": "build",
+ "problemMatcher": ["$msCompile"]
+ },
+ {
+ "label": "build-agentServer",
+ "type": "shell",
+ "command": "dotnet",
+ "args": ["build", "${workspaceFolder}/samples/AgentServer/AgentServer.csproj"],
+ "group": "build",
+ "problemMatcher": ["$msCompile"]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/A2A.slnx b/A2A.slnx
index 8544520b..07be1f60 100644
--- a/A2A.slnx
+++ b/A2A.slnx
@@ -19,6 +19,7 @@
+
@@ -31,5 +32,6 @@
+
diff --git a/README.md b/README.md
index 35c0c56e..de45bd34 100644
--- a/README.md
+++ b/README.md
@@ -1,207 +1,228 @@
-# A2A .NET SDK
-
-[](LICENSE)
-[](https://www.nuget.org/packages/A2A/)
-
-A .NET library that helps run agentic applications as A2AServers following the [Agent2Agent (A2A) Protocol](https://a2a-protocol.org).
-
-The A2A .NET SDK provides a robust implementation of the Agent2Agent (A2A) protocol, enabling seamless communication between AI agents and applications. This library offers both high-level abstractions and fine-grained control, making it easy to build A2A-compatible agents while maintaining flexibility for advanced use cases.
-
-Key features include:
-- **Agent Capability Discovery**: Retrieve agent capabilities and metadata through agent cards
-- **Message-based Communication**: Direct, stateless messaging with immediate responses
-- **Task-based Communication**: Create and manage persistent, long-running agent tasks
-- **Streaming Support**: Real-time communication using Server-Sent Events
-- **ASP.NET Core Integration**: Built-in extensions for hosting A2A agents in web applications
-- **Cross-platform Compatibility**: Supports .NET Standard 2.0 and .NET 8+
-
-## Protocol Compatibility
-
-This library implements most of the features of protocol v0.2.6, however there are some scenarios that are not yet complete for full compatibility with this version. A complete list of outstanding compatibility items can be found at: [open compatibility items](https://github.com/a2aproject/a2a-dotnet/issues?q=is:issue%20is:open%20(label:v0.2.4%20OR%20label:v0.2.5%20OR%20label:v0.2.6))
-
-## Installation
-
-### Core A2A Library
-
-```bash
-dotnet add package A2A
-```
-
-### ASP.NET Core Extensions
-
-```bash
-dotnet add package A2A.AspNetCore
-```
-
-## Overview
-
-
-## Library: A2A
-This library contains the core A2A protocol implementation. It includes the following key classes:
-
-### Client Classes
-- **`A2AClient`**: Primary client for making A2A requests to agents. Supports both streaming and non-streaming communication, task management, and push notifications.
-- **`A2ACardResolver`**: Resolves agent card information from A2A-compatible endpoints to discover agent capabilities and metadata.
-
-### Server Classes
-- **`TaskManager`**: Manages the complete lifecycle of agent tasks including creation, updates, cancellation, and event streaming. Handles both message-based and task-based communication patterns.
-- **`ITaskStore`**: An interface for abstracting the storage of tasks.
-- **`InMemoryTaskStore`**: Simple in-memory implementation of `ITaskStore` suitable for development and testing scenarios.
-
-### Core Models
-- **`AgentTask`**: Represents a task with its status, history, artifacts, and metadata.
-- **`AgentCard`**: Contains agent metadata, capabilities, and endpoint information.
-- **`Message`**: Represents messages exchanged between agents and clients.
-
-## Library: A2A.AspNetCore
-This library provides ASP.NET Core integration for hosting A2A agents. It includes the following key classes:
-
-### Extension Methods
-- **`A2ARouteBuilderExtensions`**: Provides `MapA2A()` and `MapHttpA2A()` extension methods for configuring A2A endpoints in ASP.NET Core applications.
-
-## Getting Started
-
-### 1. Create an Agent Server
-
-```csharp
-using A2A;
-using A2A.AspNetCore;
-
-var builder = WebApplication.CreateBuilder(args);
-var app = builder.Build();
-
-// Create and register your agent
-var taskManager = new TaskManager();
-var agent = new EchoAgent();
-agent.Attach(taskManager);
-
-app.MapA2A(taskManager, "/echo");
-app.Run();
-
-public class EchoAgent
-{
- public void Attach(ITaskManager taskManager)
- {
- taskManager.OnMessageReceived = ProcessMessageAsync;
- taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
- {
- var text = messageSendParams.Message.Parts.OfType().First().Text;
- return Task.FromResult(new Message
- {
- Role = MessageRole.Agent,
- MessageId = Guid.NewGuid().ToString(),
- ContextId = messageSendParams.Message.ContextId,
- Parts = [new TextPart { Text = $"Echo: {text}" }]
- });
- }
-
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- return Task.FromResult(new AgentCard
- {
- Name = "Echo Agent",
- Description = "Echoes messages back to the user",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = new AgentCapabilities { Streaming = true }
- });
- }
-}
-```
-
-### 2. Connect with A2AClient
-
-```csharp
-using A2A;
-
-// Discover agent and create client
-var cardResolver = new A2ACardResolver(new Uri("http://localhost:5100/"));
-var agentCard = await cardResolver.GetAgentCardAsync();
-var client = new A2AClient(new Uri(agentCard.Url));
-
-// Send message
-var response = await client.SendMessageAsync(new MessageSendParams
-{
- Message = new AgentMessage
- {
- Role = MessageRole.User,
- Parts = [new TextPart { Text = "Hello!" }]
- }
-});
-```
-
-## Samples
-
-The repository includes several sample projects demonstrating different aspects of the A2A protocol implementation. Each sample includes its own README with detailed setup and usage instructions.
-
-### Agent Client Samples
-**[`samples/AgentClient/`](samples/AgentClient/README.md)**
-
-Comprehensive collection of client-side samples showing how to interact with A2A agents:
-- **Agent Capability Discovery**: Retrieve agent capabilities and metadata using agent cards
-- **Message-based Communication**: Direct, stateless messaging with immediate responses
-- **Task-based Communication**: Create and manage persistent agent tasks
-- **Streaming Communication**: Real-time communication using Server-Sent Events
-
-### Agent Server Samples
-**[`samples/AgentServer/`](samples/AgentServer/README.md)**
-
-Server-side examples demonstrating how to build A2A-compatible agents:
-- **Echo Agent**: Simple agent that echoes messages back to clients
-- **Echo Agent with Tasks**: Task-based version of the echo agent
-- **Researcher Agent**: More complex agent with research capabilities
-- **HTTP Test Suite**: Complete set of HTTP tests for all agent endpoints
-
-### Semantic Kernel Integration
-**[`samples/SemanticKernelAgent/`](samples/SemanticKernelAgent/README.md)**
-
-Advanced sample showing integration with Microsoft Semantic Kernel:
-- **Travel Planner Agent**: AI-powered travel planning agent
-- **Semantic Kernel Integration**: Demonstrates how to wrap Semantic Kernel functionality in A2A protocol
-
-### Command Line Interface
-**[`samples/A2ACli/`](samples/A2ACli/)**
-
-Command-line tool for interacting with A2A agents:
-- Direct command-line access to A2A agents
-- Useful for testing and automation scenarios
-
-### Quick Start with Client Samples
-
-1. **Clone and build the repository**:
- ```bash
- git clone https://github.com/a2aproject/a2a-dotnet.git
- cd a2a-dotnet
- dotnet build
- ```
-
-2. **Run the client samples**:
- ```bash
- cd samples/AgentClient
- dotnet run
- ```
-
-For detailed instructions and advanced scenarios, see the individual README files linked above.
-
-## Further Reading
-
-To learn more about the A2A protocol, explore these additional resources:
-
-- **[A2A Protocol Documentation](https://a2a-protocol.org/latest/)** - The official documentation for the A2A protocol.
-- **[A2A Protocol Specification](https://a2a-protocol.org/latest/specification/)** - The detailed technical specification of the protocol.
-- **[A2A Topics](https://a2a-protocol.org/latest/topics/what-is-a2a/)** - An overview of key concepts and features of the A2A protocol.
-- **[A2A Roadmap](https://a2a-protocol.org/latest/roadmap/)** - A look at the future development plans and upcoming features.
-
-## Acknowledgements
-
-This library builds upon [Darrel Miller's](https://github.com/darrelmiller) [sharpa2a](https://github.com/darrelmiller/sharpa2a) project. Thanks to Darrel and all the other contributors for the foundational work that helped shape this SDK.
-
-## License
-
-This project is licensed under the [Apache 2.0 License](LICENSE).
-
+# A2A .NET SDK
+
+[](LICENSE)
+[](https://www.nuget.org/packages/A2A/)
+
+A .NET library that helps run agentic applications as A2AServers following the [Agent2Agent (A2A) Protocol](https://a2a-protocol.org).
+
+The A2A .NET SDK provides a robust implementation of the Agent2Agent (A2A) protocol, enabling seamless communication between AI agents and applications. This library offers both high-level abstractions and fine-grained control, making it easy to build A2A-compatible agents while maintaining flexibility for advanced use cases.
+
+Key features include:
+- **Agent Capability Discovery**: Retrieve agent capabilities and metadata through agent cards
+- **Message-based Communication**: Direct, stateless messaging with immediate responses
+- **Task-based Communication**: Create and manage persistent, long-running agent tasks
+- **Streaming Support**: Real-time communication using Server-Sent Events
+- **ASP.NET Core Integration**: Built-in extensions for hosting A2A agents in web applications
+- **Cross-platform Compatibility**: Supports .NET 8+
+
+## Protocol Compatibility
+
+This library implements the [A2A v1.0 specification](https://a2a-protocol.org). It provides full support for the JSON-RPC binding and HTTP+JSON REST binding, including streaming via Server-Sent Events.
+
+If you're upgrading from the v0.3 SDK, see the **[Migration Guide](docs/migration-guide-v1.md)** for a comprehensive list of breaking changes and before/after code examples. A backward-compatible `A2A.V0_3` NuGet package is available during the transition:
+
+```bash
+dotnet add package A2A.V0_3
+```
+
+## Installation
+
+### Core A2A Library
+
+```bash
+dotnet add package A2A
+```
+
+### ASP.NET Core Extensions
+
+```bash
+dotnet add package A2A.AspNetCore
+```
+
+## Overview
+
+
+## Library: A2A
+This library contains the core A2A protocol implementation. It includes the following key classes:
+
+### Client Classes
+- **`A2AClient`**: Primary client for making A2A requests to agents. Supports both streaming and non-streaming communication, task management, and push notifications.
+- **`A2ACardResolver`**: Resolves agent card information from A2A-compatible endpoints to discover agent capabilities and metadata.
+
+### Server Classes
+- **`TaskManager`**: Manages the complete lifecycle of agent tasks including creation, updates, cancellation, and event streaming. Handles both message-based and task-based communication patterns.
+- **`ITaskStore`**: An interface for abstracting the storage of tasks.
+- **`InMemoryTaskStore`**: Simple in-memory implementation of `ITaskStore` suitable for development and testing scenarios.
+
+### Core Models
+- **`AgentTask`**: Represents a task with its status, history, artifacts, and metadata.
+- **`AgentCard`**: Contains agent metadata, capabilities, and endpoint information.
+- **`Message`**: Represents messages exchanged between agents and clients.
+
+## Library: A2A.AspNetCore
+This library provides ASP.NET Core integration for hosting A2A agents. It includes the following key classes:
+
+### Extension Methods
+- **`A2ARouteBuilderExtensions`**: Provides `MapA2A()` and `MapHttpA2A()` extension methods for configuring A2A endpoints in ASP.NET Core applications.
+
+## Getting Started
+
+### 1. Create an Agent Server
+
+```csharp
+using A2A;
+using A2A.AspNetCore;
+
+var builder = WebApplication.CreateBuilder(args);
+var app = builder.Build();
+
+var store = new InMemoryTaskStore();
+var taskManager = new TaskManager(store);
+
+taskManager.OnSendMessage = async (request, ct) =>
+{
+ var text = request.Message.Parts.FirstOrDefault()?.Text ?? "";
+ return new SendMessageResponse
+ {
+ Message = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.Agent,
+ Parts = [Part.FromText($"Echo: {text}")]
+ }
+ };
+};
+
+var agentCard = new AgentCard
+{
+ Name = "Echo Agent",
+ Description = "Echoes messages back to the user",
+ Version = "1.0.0",
+ SupportedInterfaces = [new AgentInterface
+ {
+ Url = "http://localhost:5000/echo",
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0"
+ }],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities { Streaming = false },
+ Skills = [new AgentSkill
+ {
+ Id = "echo",
+ Name = "Echo",
+ Description = "Echoes back user messages",
+ Tags = ["echo"]
+ }],
+};
+
+app.MapA2A(taskManager, "/echo");
+app.MapWellKnownAgentCard(agentCard);
+app.Run();
+```
+
+### 2. Connect with A2AClient
+
+```csharp
+using A2A;
+
+// Discover agent
+var cardResolver = new A2ACardResolver(new Uri("http://localhost:5000/"));
+var agentCard = await cardResolver.GetAgentCardAsync();
+
+// Create client using agent's endpoint
+var client = new A2AClient(new Uri(agentCard.SupportedInterfaces[0].Url));
+
+// Send message
+var response = await client.SendMessageAsync(new SendMessageRequest
+{
+ Message = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.User,
+ Parts = [Part.FromText("Hello!")]
+ }
+});
+
+// Handle response
+switch (response.PayloadCase)
+{
+ case SendMessageResponseCase.Message:
+ Console.WriteLine(response.Message!.Parts[0].Text);
+ break;
+ case SendMessageResponseCase.Task:
+ Console.WriteLine($"Task created: {response.Task!.Id}");
+ break;
+}
+```
+
+## Samples
+
+The repository includes several sample projects demonstrating different aspects of the A2A protocol implementation. Each sample includes its own README with detailed setup and usage instructions.
+
+### Agent Client Samples
+**[`samples/AgentClient/`](samples/AgentClient/README.md)**
+
+Comprehensive collection of client-side samples showing how to interact with A2A agents:
+- **Agent Capability Discovery**: Retrieve agent capabilities and metadata using agent cards
+- **Message-based Communication**: Direct, stateless messaging with immediate responses
+- **Task-based Communication**: Create and manage persistent agent tasks
+- **Streaming Communication**: Real-time communication using Server-Sent Events
+
+### Agent Server Samples
+**[`samples/AgentServer/`](samples/AgentServer/README.md)**
+
+Server-side examples demonstrating how to build A2A-compatible agents:
+- **Echo Agent**: Simple agent that echoes messages back to clients
+- **Echo Agent with Tasks**: Task-based version of the echo agent
+- **Researcher Agent**: More complex agent with research capabilities
+- **HTTP Test Suite**: Complete set of HTTP tests for all agent endpoints
+
+### Semantic Kernel Integration
+**[`samples/SemanticKernelAgent/`](samples/SemanticKernelAgent/README.md)**
+
+Advanced sample showing integration with Microsoft Semantic Kernel:
+- **Travel Planner Agent**: AI-powered travel planning agent
+- **Semantic Kernel Integration**: Demonstrates how to wrap Semantic Kernel functionality in A2A protocol
+
+### Command Line Interface
+**[`samples/A2ACli/`](samples/A2ACli/)**
+
+Command-line tool for interacting with A2A agents:
+- Direct command-line access to A2A agents
+- Useful for testing and automation scenarios
+
+### Quick Start with Client Samples
+
+1. **Clone and build the repository**:
+ ```bash
+ git clone https://github.com/a2aproject/a2a-dotnet.git
+ cd a2a-dotnet
+ dotnet build
+ ```
+
+2. **Run the client samples**:
+ ```bash
+ cd samples/AgentClient
+ dotnet run
+ ```
+
+For detailed instructions and advanced scenarios, see the individual README files linked above.
+
+## Further Reading
+
+To learn more about the A2A protocol, explore these additional resources:
+
+- **[A2A Protocol Documentation](https://a2a-protocol.org/latest/)** - The official documentation for the A2A protocol.
+- **[A2A Protocol Specification](https://a2a-protocol.org/latest/specification/)** - The detailed technical specification of the protocol.
+- **[A2A Topics](https://a2a-protocol.org/latest/topics/what-is-a2a/)** - An overview of key concepts and features of the A2A protocol.
+- **[A2A Roadmap](https://a2a-protocol.org/latest/roadmap/)** - A look at the future development plans and upcoming features.
+
+## Acknowledgements
+
+This library builds upon [Darrel Miller's](https://github.com/darrelmiller) [sharpa2a](https://github.com/darrelmiller/sharpa2a) project. Thanks to Darrel and all the other contributors for the foundational work that helped shape this SDK.
+
+## License
+
+This project is licensed under the [Apache 2.0 License](LICENSE).
+
diff --git a/docs/migration-guide-v1.md b/docs/migration-guide-v1.md
new file mode 100644
index 00000000..a1ec2ed3
--- /dev/null
+++ b/docs/migration-guide-v1.md
@@ -0,0 +1,651 @@
+# A2A .NET SDK: Migration Guide — v0.3 to v1
+
+This guide helps .NET developers upgrade from the A2A v0.3 SDK to v1. The v1 release adopts [ProtoJSON](https://protobuf.dev/programming-guides/proto3/#json) serialization conventions (SCREAMING_SNAKE_CASE enums, field-presence `oneof` patterns), replaces discriminator-based polymorphism with flat sealed types, and adds new operations like `ListTasks` and a REST API binding.
+
+> **Note**: The `A2A.V0_3` NuGet package remains available for backward compatibility during the transition period. It will be removed in a future release.
+
+## Migration Strategy
+
+Follow this 3-phase approach (from the [official A2A spec](https://google.github.io/A2A/)):
+
+### Phase 1: Compatibility Layer
+
+Add the `A2A.V0_3` package alongside `A2A` v1. Both can coexist in the same project using different namespaces (`A2A` for v1, `A2A.V0_3` for legacy). No code changes required — existing v0.3 code continues to work.
+
+```xml
+
+
+```
+
+### Phase 2: Dual Support
+
+Update your client and server code to use v1 types. Keep the `A2A.V0_3` package for any consumers that haven't migrated yet. All the changes described in this guide apply to this phase.
+
+### Phase 3: V1-Only
+
+Remove the `A2A.V0_3` dependency entirely. All consumers are on v1.
+
+```xml
+
+
+```
+
+---
+
+## Part Model
+
+The biggest structural change. V0.3 used a discriminator hierarchy with `TextPart`, `FilePart`, and `DataPart` subclasses. V1 uses a single sealed `Part` class with field-presence (`oneof`).
+
+### V0.3
+
+```csharp
+using A2A.V0_3;
+
+// Creating parts
+var textPart = new TextPart { Text = "Hello, world!" };
+var filePart = new FilePart
+{
+ File = new FileContent
+ {
+ Name = "report.pdf",
+ MimeType = "application/pdf",
+ Uri = "https://example.com/report.pdf"
+ }
+};
+var dataPart = new DataPart
+{
+ Data = JsonDocument.Parse("""{"key": "value"}""").RootElement
+};
+
+// Consuming parts (check kind, then cast)
+foreach (Part part in message.Parts)
+{
+ switch (part)
+ {
+ case TextPart tp:
+ Console.WriteLine(tp.Text);
+ break;
+ case FilePart fp:
+ Console.WriteLine(fp.File.Uri);
+ break;
+ case DataPart dp:
+ Console.WriteLine(dp.Data);
+ break;
+ }
+}
+```
+
+### V1
+
+```csharp
+using A2A;
+
+// Creating parts — use factory methods
+var textPart = Part.FromText("Hello, world!");
+var filePart = Part.FromUrl("https://example.com/report.pdf",
+ mediaType: "application/pdf", filename: "report.pdf");
+var dataPart = Part.FromData(
+ JsonDocument.Parse("""{"key": "value"}""").RootElement);
+
+// Consuming parts — use ContentCase enum
+foreach (Part part in message.Parts)
+{
+ switch (part.ContentCase)
+ {
+ case PartContentCase.Text:
+ Console.WriteLine(part.Text);
+ break;
+ case PartContentCase.Url:
+ Console.WriteLine(part.Url);
+ break;
+ case PartContentCase.Raw:
+ // part.Raw is byte[] (base64 in JSON)
+ break;
+ case PartContentCase.Data:
+ Console.WriteLine(part.Data);
+ break;
+ }
+}
+```
+
+**Key differences:**
+
+| Aspect | V0.3 | V1 |
+|--------|------|-----|
+| Type hierarchy | `TextPart`, `FilePart`, `DataPart` subclasses | Single sealed `Part` class |
+| Content identification | `kind` discriminator + C# type casting | `ContentCase` computed enum |
+| File content | Nested `FileContent` class with `Uri`/`Bytes` | Flat: `Part.Url` or `Part.Raw` |
+| MIME type field | `MimeType` (on `FileContent`) | `MediaType` (on `Part`) |
+| Factory methods | None (direct construction) | `Part.FromText()`, `Part.FromUrl()`, `Part.FromRaw()`, `Part.FromData()` |
+
+---
+
+## Response Types
+
+V0.3 used `A2AResponse` with a `kind` discriminator. V1 splits into `SendMessageResponse` (for `SendMessage`) and `StreamResponse` (for streaming).
+
+### V0.3
+
+```csharp
+// SendMessage returned an A2AResponse with kind-based discrimination
+A2AResponse response = await client.SendMessageAsync(sendParams);
+switch (response)
+{
+ case AgentTask task:
+ Console.WriteLine($"Task: {task.Id}, State: {task.Status.State}");
+ break;
+ case TaskStatusUpdateEvent update:
+ Console.WriteLine($"Status: {update.Status.State}");
+ break;
+}
+```
+
+### V1
+
+```csharp
+// SendMessage returns SendMessageResponse with named fields
+SendMessageResponse response = await client.SendMessageAsync(request);
+switch (response.PayloadCase)
+{
+ case SendMessageResponseCase.Task:
+ Console.WriteLine($"Task: {response.Task!.Id}, State: {response.Task.Status.State}");
+ break;
+ case SendMessageResponseCase.Message:
+ Console.WriteLine($"Message from agent: {response.Message!.Parts[0].Text}");
+ break;
+}
+```
+
+### Streaming
+
+```csharp
+// V0.3: IAsyncEnumerable with kind-based casting
+await foreach (A2AEvent evt in client.SendStreamingAsync(params))
+{
+ if (evt is TaskStatusUpdateEvent statusUpdate) { ... }
+ if (evt is TaskArtifactUpdateEvent artifactUpdate) { ... }
+}
+
+// V1: IAsyncEnumerable with PayloadCase
+await foreach (StreamResponse evt in client.SendStreamingMessageAsync(request))
+{
+ switch (evt.PayloadCase)
+ {
+ case StreamResponseCase.StatusUpdate:
+ Console.WriteLine(evt.StatusUpdate!.Status.State);
+ break;
+ case StreamResponseCase.ArtifactUpdate:
+ Console.WriteLine(evt.ArtifactUpdate!.Artifact.ArtifactId);
+ break;
+ case StreamResponseCase.Task:
+ Console.WriteLine(evt.Task!.Id);
+ break;
+ case StreamResponseCase.Message:
+ Console.WriteLine(evt.Message!.Parts[0].Text);
+ break;
+ }
+}
+```
+
+---
+
+## SecurityScheme
+
+V0.3 used discriminator-based polymorphism. V1 uses field-presence on a flat sealed class.
+
+### V0.3
+
+```csharp
+// Creating security schemes
+var apiKey = new SecurityScheme
+{
+ ApiKeySecurityScheme = new ApiKeySecurityScheme
+ {
+ Name = "X-API-Key",
+ In = "header" // Note: "In" property
+ }
+};
+```
+
+### V1
+
+```csharp
+// Creating security schemes — same structure, different field names
+var apiKey = new SecurityScheme
+{
+ ApiKeySecurityScheme = new ApiKeySecurityScheme
+ {
+ Name = "X-API-Key",
+ Location = "header" // Renamed: "In" → "Location"
+ }
+};
+
+// Use SchemeCase for type identification
+switch (scheme.SchemeCase)
+{
+ case SecuritySchemeCase.ApiKey:
+ Console.WriteLine(scheme.ApiKeySecurityScheme!.Location);
+ break;
+ case SecuritySchemeCase.OAuth2:
+ Console.WriteLine(scheme.OAuth2SecurityScheme!.Flows.FlowCase);
+ break;
+}
+```
+
+**Key change:** `ApiKeySecurityScheme.In` is renamed to `ApiKeySecurityScheme.Location` (JSON property `"location"` to match proto).
+
+---
+
+## Type Renames
+
+| V0.3 | V1 | Notes |
+|------|-----|-------|
+| `AgentMessage` | `Message` | Renamed |
+| `AgentTaskStatus` | `TaskStatus` | Struct → sealed class |
+| `MessageRole` | `Role` | Renamed |
+| `TextPart` | `Part` | Unified (use `Part.FromText()`) |
+| `FilePart` | `Part` | Unified (use `Part.FromUrl()` / `Part.FromRaw()`) |
+| `DataPart` | `Part` | Unified (use `Part.FromData()`) |
+| `A2AResponse` | `SendMessageResponse` / `StreamResponse` | Split by use case |
+| `A2AEvent` | `StreamResponse` | Unified with response |
+| `FileContent` | (removed) | Fields moved to `Part` directly |
+| `PartKind` | `PartContentCase` | Computed enum |
+| `A2AEventKind` | `StreamResponseCase` | Computed enum |
+
+---
+
+## JSON Wire Format
+
+All JSON wire format changes follow the A2A v1 ProtoJSON conventions.
+
+### Enum Values
+
+```json
+// V0.3 (kebab-case)
+{ "state": "input-required" }
+{ "role": "user" }
+
+// V1 (SCREAMING_SNAKE_CASE with type prefix)
+{ "state": "TASK_STATE_INPUT_REQUIRED" }
+{ "role": "ROLE_USER" }
+```
+
+**Full enum mapping:**
+
+| V0.3 | V1 |
+|------|-----|
+| `"submitted"` | `"TASK_STATE_SUBMITTED"` |
+| `"working"` | `"TASK_STATE_WORKING"` |
+| `"input-required"` | `"TASK_STATE_INPUT_REQUIRED"` |
+| `"completed"` | `"TASK_STATE_COMPLETED"` |
+| `"canceled"` | `"TASK_STATE_CANCELED"` |
+| `"failed"` | `"TASK_STATE_FAILED"` |
+| `"user"` | `"ROLE_USER"` |
+| `"agent"` | `"ROLE_AGENT"` |
+
+**New v1 enum values** (not in v0.3): `TASK_STATE_REJECTED`, `TASK_STATE_AUTH_REQUIRED`, `TASK_STATE_UNSPECIFIED`.
+
+### Part Format
+
+```json
+// V0.3 — kind discriminator
+{"kind": "text", "text": "Hello"}
+{"kind": "file", "file": {"uri": "https://...", "mimeType": "text/plain"}}
+
+// V1 — field-presence (no kind, flat structure)
+{"text": "Hello"}
+{"url": "https://...", "mediaType": "text/plain"}
+```
+
+### Response Format
+
+```json
+// V0.3 — kind at root level
+{
+ "result": {"kind": "task", "id": "abc", "status": {"state": "working"}}
+}
+
+// V1 — named wrapper
+{
+ "result": {"task": {"id": "abc", "status": {"state": "TASK_STATE_WORKING"}}}
+}
+```
+
+### JSON-RPC Method Names
+
+| V0.3 | V1 |
+|------|-----|
+| `"message/send"` | `"SendMessage"` |
+| `"message/stream"` | `"SendStreamingMessage"` |
+| `"tasks/get"` | `"GetTask"` |
+| `"tasks/cancel"` | `"CancelTask"` |
+| `"tasks/pushNotificationConfig/set"` | `"CreateTaskPushNotificationConfig"` |
+| `"tasks/pushNotificationConfig/get"` | `"GetTaskPushNotificationConfig"` |
+| `"tasks/resubscribe"` | `"SubscribeToTask"` |
+| N/A | `"ListTasks"` (new) |
+| N/A | `"DeleteTaskPushNotificationConfig"` (new) |
+| N/A | `"ListTaskPushNotificationConfig"` (new) |
+| N/A | `"GetExtendedAgentCard"` (new) |
+
+---
+
+## AgentCard Changes
+
+### V0.3
+
+```csharp
+var card = new AgentCard
+{
+ Name = "My Agent",
+ Description = "Does things.",
+ Url = "http://localhost:5000/agent",
+ // No Version field
+ // protocolVersion at top level
+ Skills = [new AgentSkill { Id = "skill1", Name = "Skill", Description = "Desc" }],
+};
+```
+
+### V1
+
+```csharp
+var card = new AgentCard
+{
+ Name = "My Agent",
+ Description = "Does things.",
+ Version = "1.0.0", // Required in v1
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = "http://localhost:5000/agent",
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0"
+ }
+ ],
+ DefaultInputModes = ["text/plain"], // Required in v1
+ DefaultOutputModes = ["text/plain"], // Required in v1
+ Capabilities = new AgentCapabilities { Streaming = true }, // Required in v1
+ Skills =
+ [
+ new AgentSkill
+ {
+ Id = "skill1",
+ Name = "Skill",
+ Description = "Description",
+ Tags = ["example"] // Required in v1
+ }
+ ],
+};
+```
+
+**Key differences:**
+
+| Field | V0.3 | V1 |
+|-------|------|-----|
+| `Url` | Top-level string | Removed (use `SupportedInterfaces[0].Url`) |
+| `Version` | Not present | Required `string` |
+| `SupportedInterfaces` | Not present | Required `AgentInterface[]` |
+| `ProtocolVersion` | Top-level | In `AgentInterface.ProtocolVersion` |
+| `Icons` | `List?` (complex type) | `string? IconUrl` |
+| `DocumentationUrl` | Not present | Optional `string?` |
+| `Capabilities` | Optional | Required (non-nullable) |
+| `Skills` | Optional | Required (non-nullable) |
+| `DefaultInputModes` | Optional | Required (non-nullable) |
+| `DefaultOutputModes` | Optional | Required (non-nullable) |
+| `AgentSkill.Tags` | Optional | Required (non-nullable) |
+
+---
+
+## Client API Changes
+
+### Interface
+
+```csharp
+// V0.3
+public interface IA2AClient : IDisposable { ... }
+
+// V1 — IDisposable removed from interface
+public interface IA2AClient { ... }
+
+// A2AClient (concrete) still implements IDisposable directly:
+public sealed class A2AClient : IA2AClient, IDisposable { ... }
+```
+
+### Parameter Types
+
+V0.3 used inline parameter classes. V1 uses named request objects:
+
+```csharp
+// V0.3
+var sendParams = new MessageSendParams
+{
+ Message = new AgentMessage { Role = MessageRole.User, Parts = [new TextPart { Text = "Hi" }] }
+};
+var response = await client.SendMessageAsync(sendParams);
+
+// V1
+var request = new SendMessageRequest
+{
+ Message = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.User,
+ Parts = [Part.FromText("Hi")]
+ }
+};
+var response = await client.SendMessageAsync(request);
+```
+
+### New Operations
+
+V1 adds these operations not available in v0.3:
+
+```csharp
+// List tasks with pagination and filtering
+var listResult = await client.ListTasksAsync(new ListTasksRequest
+{
+ ContextId = "my-context",
+ PageSize = 10,
+ Status = TaskState.Working,
+});
+
+// Delete push notification config
+await client.DeleteTaskPushNotificationConfigAsync(
+ new DeleteTaskPushNotificationConfigRequest { TaskId = "t1", Id = "config1" });
+
+// Get extended agent card
+var extCard = await client.GetExtendedAgentCardAsync(
+ new GetExtendedAgentCardRequest());
+```
+
+---
+
+## Server API
+
+V1 introduces `ITaskManager` for server implementations. Use `TaskManager` as a base or implement the interface directly.
+
+```csharp
+// Register handlers
+var taskManager = new TaskManager(store, logger);
+
+taskManager.OnSendMessage = async (request, ct) =>
+{
+ // Process the message and return a response
+ var task = await store.CreateTaskAsync(...);
+ return new SendMessageResponse { Task = task };
+};
+
+taskManager.OnCancelTask = async (request, ct) =>
+{
+ var task = await store.GetTaskAsync(request.Id);
+ await store.UpdateStatusAsync(task.Id, new TaskStatus { State = TaskState.Canceled });
+ return task;
+};
+
+// Map endpoints
+app.MapA2A(taskManager, "/agent");
+app.MapWellKnownAgentCard(agentCard);
+
+// Optional: also map REST API
+app.MapHttpA2A(taskManager, agentCard);
+```
+
+---
+
+## New V1 Features
+
+### ListTasks
+
+Full pagination, filtering, and sorting support:
+
+```csharp
+var result = await client.ListTasksAsync(new ListTasksRequest
+{
+ ContextId = "conversation-123",
+ Status = TaskState.Working,
+ PageSize = 10,
+ PageToken = nextPageToken,
+ HistoryLength = 5,
+ IncludeArtifacts = true,
+ StatusTimestampAfter = DateTimeOffset.UtcNow.AddHours(-1),
+});
+
+foreach (var task in result.Tasks)
+{
+ Console.WriteLine($"{task.Id}: {task.Status.State}");
+}
+
+// Pagination
+if (!string.IsNullOrEmpty(result.NextPageToken))
+{
+ // Fetch next page...
+}
+```
+
+### REST API
+
+V1 adds an HTTP+JSON REST binding alongside JSON-RPC:
+
+```csharp
+// Server-side: map both bindings
+app.MapA2A(taskManager, "/agent"); // JSON-RPC
+app.MapHttpA2A(taskManager, agentCard); // REST API
+
+// REST endpoints:
+// GET /v1/card
+// GET /v1/tasks/{id}
+// POST /v1/tasks/{id}:cancel
+// GET /v1/tasks
+// POST /v1/message:send
+// POST /v1/message:stream
+// ... and more
+```
+
+### Version Negotiation
+
+The JSON-RPC binding supports version negotiation via the `A2A-Version` header:
+
+- Empty or missing → accepted (defaults to current)
+- `"0.3"` → accepted
+- `"1.0"` → accepted
+- Any other value → `VersionNotSupported` error (-32009)
+
+### ContentCase / PayloadCase Enums
+
+All proto `oneof` types have computed case enums for safe switching:
+
+- `Part.ContentCase` → `PartContentCase { None, Text, Raw, Url, Data }`
+- `SendMessageResponse.PayloadCase` → `SendMessageResponseCase { None, Task, Message }`
+- `StreamResponse.PayloadCase` → `StreamResponseCase { None, Task, Message, StatusUpdate, ArtifactUpdate }`
+- `SecurityScheme.SchemeCase` → `SecuritySchemeCase { None, ApiKey, HttpAuth, OAuth2, OpenIdConnect, Mtls }`
+- `OAuthFlows.FlowCase` → `OAuthFlowCase { None, AuthorizationCode, ClientCredentials, ... }`
+
+### New Error Codes
+
+| Code | Name | When |
+|------|------|------|
+| -32006 | `InvalidAgentResponse` | Internal agent response error |
+| -32007 | `ExtendedAgentCardNotConfigured` | Extended card not available |
+| -32008 | `ExtensionSupportRequired` | Extension not supported |
+| -32009 | `VersionNotSupported` | Invalid A2A-Version header |
+
+---
+
+## Backward Compatibility
+
+During migration, you can use the `A2A.V0_3` NuGet package for backward compatibility:
+
+```csharp
+// V0.3 namespace
+using A2A.V0_3;
+
+// V1 namespace
+using A2A;
+
+// Both can coexist — use fully qualified names when ambiguous
+var v03Part = new A2A.V0_3.TextPart { Text = "hello" };
+var v1Part = A2A.Part.FromText("hello");
+```
+
+The `A2A.V0_3` package is a standalone snapshot of the v0.3 SDK. It has no dependencies on the v1 package and can be used indefinitely during the transition period.
+
+---
+
+## Task Store (Event Sourcing)
+
+The v1 SDK replaces the mutable `ITaskStore` with an append-only `ITaskEventStore` backed by event sourcing. This change eliminates race conditions, enables spec-compliant subscribe/resubscribe, and fixes artifact append semantics.
+
+### What Changed
+
+| Before | After |
+|--------|-------|
+| `ITaskStore` (5 mutation methods) | `ITaskEventStore` (append-only + projection queries) |
+| `InMemoryTaskStore` | `InMemoryEventStore` |
+| `A2AServer(handler, ITaskStore, ...)` | `A2AServer(handler, ITaskEventStore, ...)` |
+| `services.TryAddSingleton()` | `services.TryAddSingleton()` |
+
+### Default Registration (No Changes Needed)
+
+If you use `AddA2AAgent()` for DI registration, no code changes are required. The default registration now uses `InMemoryEventStore`:
+
+```csharp
+// This still works — InMemoryEventStore is registered automatically
+builder.Services.AddA2AAgent(agentCard);
+```
+
+### Custom Task Store Migration
+
+If you had a custom `ITaskStore`, implement `ITaskEventStore` directly:
+
+```csharp
+// Before
+services.AddSingleton(new MyCustomTaskStore());
+services.AddSingleton(sp =>
+ new A2AServer(handler, sp.GetRequiredService(), logger));
+
+// After — implement ITaskEventStore
+services.AddSingleton(new MyCustomEventStore());
+services.AddSingleton(sp =>
+ new A2AServer(handler, sp.GetRequiredService(), logger));
+```
+
+### Manual A2AServer Construction
+
+If you construct `A2AServer` directly:
+
+```csharp
+// Before
+var store = new InMemoryTaskStore();
+var server = new A2AServer(handler, store, logger);
+
+// After
+var eventStore = new InMemoryEventStore();
+var server = new A2AServer(handler, eventStore, logger);
+```
+
+### Benefits
+
+- **Subscribe/resubscribe**: `SubscribeToTaskAsync` now delivers catch-up events then live events, completing on terminal state
+- **No race conditions**: Append-only design eliminates read-modify-write races in artifact persistence
+- **Correct artifact semantics**: `append=true` extends parts, `append=false` adds or replaces by artifact ID
+- **History alignment**: Superseded status messages are moved to history (Python SDK alignment)
diff --git a/docs/taskmanager-migration-guide.md b/docs/taskmanager-migration-guide.md
new file mode 100644
index 00000000..28113fdc
--- /dev/null
+++ b/docs/taskmanager-migration-guide.md
@@ -0,0 +1,424 @@
+# TaskManager Migration Guide: v0.3 to v1
+
+This guide helps you migrate existing A2A agent implementations from the v0.3
+`TaskManager` callback patterns to the v1 API.
+
+## Overview of changes
+
+The v1 `TaskManager` is fundamentally simpler. Instead of four lifecycle
+callbacks and automatic task management, you get one main callback where you
+own the entire response.
+
+| Aspect | v0.3 | v1 |
+|--------|------|-----|
+| Callbacks | `OnMessageReceived`, `OnTaskCreated`, `OnTaskCancelled`, `OnTaskUpdated` | `OnSendMessage`, `OnSendStreamingMessage`, `OnCancelTask` |
+| Task creation | Automatic (TaskManager creates tasks) | Manual (your code creates tasks via `ITaskStore`) |
+| Status updates | `taskManager.UpdateStatusAsync(taskId, state, message)` | `store.UpdateStatusAsync(taskId, status)` directly |
+| Artifacts | `taskManager.ReturnArtifactAsync(taskId, artifact)` | Set `task.Artifacts` before returning |
+| Agent card | `OnAgentCardQuery` callback | `MapWellKnownAgentCard()` extension method |
+| Constructor | `TaskManager(HttpClient?, ITaskStore?)` | `TaskManager(ITaskStore, ILogger)` |
+| Return type | `A2AResponse` (polymorphic: cast to `AgentTask`, `TaskStatusUpdateEvent`, etc.) | `SendMessageResponse` (set `.Message` or `.Task`) |
+
+## Step-by-step migration
+
+### Step 1: Update constructor and wiring
+
+**v0.3:**
+
+```csharp
+var taskManager = new TaskManager();
+agent.Attach(taskManager);
+app.MapA2A(taskManager, "/agent");
+```
+
+**v1:**
+
+```csharp
+var store = new InMemoryTaskStore();
+var logger = loggerFactory.CreateLogger();
+var taskManager = new TaskManager(store, logger);
+agent.Attach(taskManager, store); // pass store to agent
+app.MapA2A(taskManager, "/agent");
+app.MapWellKnownAgentCard(agentCard); // agent card served separately
+```
+
+Key differences:
+- `ITaskStore` and `ILogger` are required constructor parameters.
+- Your agent receives the `ITaskStore` so it can read/write tasks directly.
+- Agent card is no longer served via a callback; use `MapWellKnownAgentCard()`.
+
+### Step 2: Replace OnAgentCardQuery
+
+**v0.3:**
+
+```csharp
+taskManager.OnAgentCardQuery = (url, ct) =>
+ Task.FromResult(new AgentCard { Name = "My Agent", Url = url, ... });
+```
+
+**v1:**
+
+```csharp
+// In Program.cs — static registration
+var card = new AgentCard
+{
+ Name = "My Agent",
+ Version = "1.0.0",
+ SupportedInterfaces = [new AgentInterface
+ {
+ Url = "http://localhost:5000/agent",
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0"
+ }],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities { Streaming = false },
+ Skills = [new AgentSkill { Id = "main", Name = "Main", Description = "...", Tags = ["main"] }],
+};
+app.MapWellKnownAgentCard(card);
+```
+
+### Step 3: Migrate simple message-only agents
+
+If your v0.3 agent used `OnMessageReceived` and returned a direct response
+without creating tasks, migration is straightforward.
+
+**v0.3:**
+
+```csharp
+taskManager.OnMessageReceived = async (msgParams, ct) =>
+{
+ var text = msgParams.Message.Parts.OfType().First().Text;
+ return new AgentMessage
+ {
+ Role = MessageRole.Agent,
+ MessageId = Guid.NewGuid().ToString(),
+ ContextId = msgParams.Message.ContextId,
+ Parts = [new TextPart { Text = $"Echo: {text}" }]
+ };
+};
+```
+
+**v1:**
+
+```csharp
+taskManager.OnSendMessage = (request, ct) =>
+{
+ var text = request.Message.Parts.FirstOrDefault(p => p.Text is not null)?.Text ?? "";
+ var response = new Message
+ {
+ Role = Role.Agent,
+ MessageId = Guid.NewGuid().ToString("N"),
+ ContextId = request.Message.ContextId,
+ Parts = [Part.FromText($"Echo: {text}")]
+ };
+ return Task.FromResult(new SendMessageResponse { Message = response });
+};
+```
+
+What changed:
+- `MessageSendParams` → `SendMessageRequest`
+- `AgentMessage` → `Message`
+- `MessageRole.Agent` → `Role.Agent`
+- `TextPart { Text = ... }` → `Part.FromText(...)`
+- Return `SendMessageResponse { Message = ... }` instead of bare `AgentMessage`
+
+### Step 4: Migrate task-based agents
+
+If your v0.3 agent used the automatic task lifecycle (OnTaskCreated, OnTaskUpdated,
+UpdateStatusAsync, ReturnArtifactAsync), you need to restructure.
+
+**v0.3:**
+
+```csharp
+// Agent received callbacks at different lifecycle stages
+taskManager.OnTaskCreated = async (task, ct) =>
+{
+ // Start processing
+ await taskManager.UpdateStatusAsync(task.Id, TaskState.Working, null, false, ct);
+
+ // Do work...
+ var result = await DoWorkAsync(task, ct);
+
+ // Return artifact
+ await taskManager.ReturnArtifactAsync(task.Id, new Artifact
+ {
+ ArtifactId = "result",
+ Parts = [new TextPart { Text = result }]
+ });
+
+ // Mark complete
+ await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, null, true, ct);
+};
+
+taskManager.OnTaskCancelled = async (task, ct) =>
+{
+ // Cleanup...
+};
+```
+
+**v1:**
+
+```csharp
+public void Attach(TaskManager taskManager, ITaskStore store)
+{
+ _store = store;
+ taskManager.OnSendMessage = OnSendMessageAsync;
+ taskManager.OnCancelTask = OnCancelTaskAsync;
+}
+
+private async Task OnSendMessageAsync(
+ SendMessageRequest request, CancellationToken ct)
+{
+ // You create the task yourself
+ var taskId = Guid.NewGuid().ToString("N");
+ var contextId = request.Message.ContextId ?? Guid.NewGuid().ToString("N");
+
+ // Do work...
+ var result = await DoWorkAsync(request, ct);
+
+ // Build the complete task with status, history, and artifacts
+ var task = new AgentTask
+ {
+ Id = taskId,
+ ContextId = contextId,
+ Status = new TaskStatus
+ {
+ State = TaskState.Completed,
+ Timestamp = DateTimeOffset.UtcNow,
+ },
+ History = [request.Message],
+ Artifacts =
+ [
+ new Artifact
+ {
+ ArtifactId = Guid.NewGuid().ToString("N"),
+ Parts = [Part.FromText(result)]
+ }
+ ],
+ };
+
+ // Persist
+ await _store!.SetTaskAsync(task, ct);
+
+ // Return the task directly
+ return new SendMessageResponse { Task = task };
+}
+
+private async Task OnCancelTaskAsync(
+ CancelTaskRequest request, CancellationToken ct)
+{
+ var task = await _store!.GetTaskAsync(request.Id, ct)
+ ?? throw new A2AException($"Task '{request.Id}' not found.", A2AErrorCode.TaskNotFound);
+
+ return await _store.UpdateStatusAsync(task.Id, new TaskStatus
+ {
+ State = TaskState.Canceled,
+ Timestamp = DateTimeOffset.UtcNow,
+ }, ct);
+}
+```
+
+What changed:
+- No more `OnTaskCreated`/`OnTaskUpdated` — everything happens in `OnSendMessage`
+- You create `AgentTask` objects directly instead of calling `taskManager.CreateTaskAsync()`
+- You set artifacts on the task object instead of calling `taskManager.ReturnArtifactAsync()`
+- You persist via `ITaskStore` directly instead of `taskManager.UpdateStatusAsync()`
+- `OnCancelTask` replaces `OnTaskCancelled` (note: different signature)
+
+### Step 5: Migrate multi-turn / stateful agents
+
+For agents that maintain conversation state across multiple messages:
+
+**v0.3:**
+
+```csharp
+taskManager.OnTaskUpdated = async (task, ct) =>
+{
+ // TaskManager auto-appended the new message to task.History
+ var lastMessage = task.History!.Last();
+ // Process the follow-up message...
+ await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed);
+};
+```
+
+**v1:**
+
+```csharp
+private async Task OnSendMessageAsync(
+ SendMessageRequest request, CancellationToken ct)
+{
+ // Check if this references an existing task
+ if (!string.IsNullOrEmpty(request.Message.TaskId))
+ {
+ var existing = await _store!.GetTaskAsync(request.Message.TaskId, ct);
+ if (existing is not null)
+ {
+ // Append the new user message
+ await _store.AppendHistoryAsync(existing.Id, request.Message, ct);
+
+ // Process the follow-up...
+ var reply = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.Agent,
+ TaskId = existing.Id,
+ ContextId = existing.ContextId,
+ Parts = [Part.FromText("Processed your follow-up.")],
+ };
+ await _store.AppendHistoryAsync(existing.Id, reply, ct);
+
+ // Update status
+ await _store.UpdateStatusAsync(existing.Id, new TaskStatus
+ {
+ State = TaskState.Completed,
+ Timestamp = DateTimeOffset.UtcNow,
+ }, ct);
+
+ var updated = await _store.GetTaskAsync(existing.Id, ct);
+ return new SendMessageResponse { Task = updated };
+ }
+ }
+
+ // New conversation — create initial task
+ var task = new AgentTask { /* ... */ };
+ await _store!.SetTaskAsync(task, ct);
+ return new SendMessageResponse { Task = task };
+}
+```
+
+Key insight: In v0.3, `TaskManager` routed messages to
+`OnTaskCreated` (new) or `OnTaskUpdated` (existing) for you. In v1, your
+single `OnSendMessage` callback handles both cases — check
+`request.Message.TaskId` to distinguish them.
+
+### Step 6: Migrate streaming agents
+
+**v0.3:**
+
+```csharp
+// TaskManager handled streaming internally via TaskUpdateEventEnumerator
+// Agent pushed events through taskManager methods:
+taskManager.OnTaskCreated = async (task, ct) =>
+{
+ await taskManager.UpdateStatusAsync(task.Id, TaskState.Working);
+ // ... do work ...
+ await taskManager.ReturnArtifactAsync(task.Id, artifact);
+ await taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, null, true);
+ // TaskManager routed these to the SSE stream automatically
+};
+```
+
+**v1:**
+
+```csharp
+taskManager.OnSendStreamingMessage = (request, ct) =>
+{
+ return StreamWorkAsync(request, ct);
+};
+
+private async IAsyncEnumerable StreamWorkAsync(
+ SendMessageRequest request,
+ [EnumeratorCancellation] CancellationToken ct)
+{
+ var taskId = Guid.NewGuid().ToString("N");
+ var contextId = request.Message.ContextId ?? Guid.NewGuid().ToString("N");
+
+ // Create and persist the initial task
+ var task = new AgentTask
+ {
+ Id = taskId,
+ ContextId = contextId,
+ Status = new TaskStatus
+ {
+ State = TaskState.Working,
+ Timestamp = DateTimeOffset.UtcNow,
+ },
+ History = [request.Message],
+ };
+ await _store!.SetTaskAsync(task, ct);
+
+ // Emit status update
+ yield return new StreamResponse
+ {
+ StatusUpdate = new TaskStatusUpdateEvent
+ {
+ TaskId = taskId,
+ ContextId = contextId,
+ Status = new TaskStatus { State = TaskState.Working }
+ }
+ };
+
+ // Do work and emit artifact chunks
+ var artifact = new Artifact
+ {
+ ArtifactId = Guid.NewGuid().ToString("N"),
+ Parts = [Part.FromText("Partial result...")]
+ };
+ yield return new StreamResponse
+ {
+ ArtifactUpdate = new TaskArtifactUpdateEvent
+ {
+ TaskId = taskId,
+ ContextId = contextId,
+ Artifact = artifact,
+ Append = true,
+ LastChunk = false
+ }
+ };
+
+ // Persist final state with artifacts
+ task.Status = new TaskStatus
+ {
+ State = TaskState.Completed,
+ Timestamp = DateTimeOffset.UtcNow,
+ };
+ task.Artifacts = [artifact];
+ await _store.SetTaskAsync(task, ct);
+
+ // Emit final task
+ yield return new StreamResponse { Task = task };
+}
+```
+
+What changed:
+- Instead of calling `UpdateStatusAsync`/`ReturnArtifactAsync` on
+ TaskManager, you `yield return` `StreamResponse` objects.
+- Each `StreamResponse` uses field-presence: set `.StatusUpdate`,
+ `.ArtifactUpdate`, `.Message`, or `.Task`.
+- You control the stream directly via `IAsyncEnumerable`.
+
+## Type mapping quick reference
+
+| v0.3 type | v1 type |
+|-----------|---------|
+| `MessageSendParams` | `SendMessageRequest` |
+| `AgentMessage` | `Message` |
+| `MessageRole` | `Role` |
+| `TextPart` | `Part.FromText(...)` |
+| `FilePart` + `FileContent` | `Part.FromUrl(...)` or `Part.FromRaw(...)` |
+| `DataPart` | `Part.FromData(...)` |
+| `A2AResponse` | `SendMessageResponse` |
+| `A2AEvent` | `StreamResponse` |
+| `AgentTaskStatus` | `TaskStatus` |
+| `TaskIdParams` | `GetTaskRequest` or `CancelTaskRequest` |
+| `TaskQueryParams` | `GetTaskRequest` |
+
+## Common migration issues
+
+1. **`using TaskStatus = A2A.TaskStatus;`** — Add this to files that use
+ `TaskStatus`, since `System.Threading.Tasks.TaskStatus` conflicts.
+
+2. **`Part` is no longer abstract** — Replace `switch (part) { case TextPart:
+ ... }` with `switch (part.ContentCase) { case PartContentCase.Text: ... }`.
+
+3. **AgentCard has new required fields** — `Version`,
+ `SupportedInterfaces`, `Capabilities`, `Skills`,
+ `DefaultInputModes`, `DefaultOutputModes` are all required in v1.
+
+4. **TaskManager constructor requires ITaskStore and ILogger** — Both are
+ mandatory. Use `InMemoryTaskStore` for simple cases.
+
+5. **No more `Final` flag on streaming** — v0.3 used
+ `UpdateStatusAsync(..., final: true)` to signal end of stream. In v1 the
+ stream ends when your `IAsyncEnumerable` completes.
diff --git a/pr-subscribe-separation.md b/pr-subscribe-separation.md
new file mode 100644
index 00000000..e52d08de
--- /dev/null
+++ b/pr-subscribe-separation.md
@@ -0,0 +1,132 @@
+# A2A .NET SDK v1 Implementation
+
+## Summary
+
+Complete implementation of the A2A v1 specification for the .NET SDK, including protocol models, server-side architecture redesign, simple CRUD task store, atomic subscribe, observability, and backward compatibility with v0.3.
+
+**239 files changed, 19,753 insertions, 8,769 deletions** across 14 commits.
+
+## How to Review
+
+This PR is large but logically layered. Recommended review order by commit:
+
+| # | Commit | Focus Area | Key Files |
+|---|--------|-----------|-----------|
+| 1 | `d223872` | **v1 spec models + SDK refactor** | `src/A2A/Models/`, client, v0.3 compat |
+| 2 | `3ba763c` | Review findings fixes | Tests, docs, cleanup |
+| 3 | `9e11c78` | **Security hardening** | `A2AHttpProcessor.cs`, `A2AJsonRpcProcessor.cs` |
+| 4 | `0ebe07a` | **TFM update** (net10.0+net8.0) | `.csproj` files, removed polyfills |
+| 5 | `ad6e513` | **Server architecture redesign** | `A2AServer.cs`, `IAgentHandler.cs`, DI, observability |
+| 6 | `2f8e41f` | REST handler tracing | AspNetCore processors |
+| 7 | `9979994` | Stale task fix | `MaterializeResponseAsync`, `CancelTaskAsync` |
+| 8 | `03328b2` | Event sourcing (intermediate) | `IEventStore.cs`, `InMemoryEventStore.cs` |
+| 9 | `bb859a1` | FileEventStore sample (intermediate) | `samples/AgentServer/FileEventStore.cs` |
+| 10 | `31a57ba` | FileStoreDemo sample | `samples/FileStoreDemo/` |
+| 11 | `6cc23bb` | Subscribe separation (intermediate) | `IEventSubscriber.cs`, `ChannelEventNotifier.cs` |
+| 12 | `a7df8a6` | **Replace ES with CRUD ITaskStore** | `ITaskStore.cs`, `InMemoryTaskStore.cs`, atomic subscribe |
+| 13 | `4cf8a59`+`f0bd87e` | **Cleanup** | `TaskCreatedCount` fix, `ApplyEventAsync` rename |
+| 14 | `752caf0` | **CancelTask metadata** (spec late change) | `CancelTaskRequest.cs` |
+
+> **Note:** Commits 8-11 introduce event sourcing then commit 12 replaces it with a simpler CRUD store. This was an intentional design evolution — the event-sourcing approach was analyzed against the spec, found to be unnecessary burden for store implementors, and replaced with a 4-method CRUD interface + atomic per-task locking. See the [design discussion](#8-store-architecture-evolution) below.
+
+## Major Changes
+
+### 1. A2A v1 Spec Models (`d223872`)
+
+- Fix 16 proto fidelity gaps across 46 model types
+- Add `ContentCase`/`PayloadCase` computed enums for all `oneof` types
+- Implement full `ListTasks` with pagination, sorting, filtering
+- Port REST API (`MapHttpA2A`) with 11 endpoints and SSE streaming
+- Add `A2A-Version` header negotiation to JSON-RPC processor
+- Create `A2A.V0_3` backward compatibility project (65 files, 255 tests)
+- Add `docs/migration-guide-v1.md` (12 sections, 21 breaking changes documented)
+
+### 2. Security Hardening (`9e11c78`)
+
+- Sanitize raw `Exception.Message` in 5 HTTP/JSON-RPC error responses to prevent leaking internal details (stack traces, file paths, DB errors)
+- Sanitize `JsonException.Message` in `DeserializeAndValidate` to avoid exposing internal type/path details
+- `A2AException.Message` still exposed (intentional, app-defined errors)
+- Add error handling to SSE streaming (best-effort JSON-RPC error event on failure)
+
+### 3. Target Framework Update (`0ebe07a`)
+
+- `net9.0;net8.0;netstandard2.0` → `net10.0;net8.0` across all projects
+- Conditionally exclude `System.Linq.AsyncEnumerable` and `System.Net.ServerSentEvents` for net10.0 (now in-box)
+- Remove 8 polyfill files and all `#if NET` conditional compilation blocks
+
+### 4. Server Architecture Redesign (`ad6e513`)
+
+**BREAKING**: `ITaskManager`/`TaskManager` → `IA2ARequestHandler`/`A2AServer`
+
+- **`A2AServer`**: Lifecycle orchestrator with context resolution, terminal state guards, event persistence, history management, and try/finally safety net preventing deadlocks
+- **`IAgentHandler`**: Easy-path agent contract (`ExecuteAsync` + default `CancelAsync`)
+- **`AgentEventQueue`**: Channel-backed event stream with typed `Enqueue*` methods
+- **`TaskUpdater`**: Lifecycle convenience helper for all 8 task states
+- **`A2ADiagnostics`**: `ActivitySource('A2A')` + `Meter('A2A')` with 9 metric instruments
+- **DI**: `AddA2AAgent()` one-line registration + `MapA2A(path)` endpoint mapping
+- All sample agents rewritten: 67-144 lines → 20-97 lines
+
+### 5. Simple CRUD Task Store (`a7df8a6`)
+
+Replace event sourcing with a 4-method `ITaskStore` interface matching the Python SDK pattern:
+
+```csharp
+public interface ITaskStore
+{
+ Task GetTaskAsync(string taskId, ...);
+ Task SaveTaskAsync(string taskId, AgentTask task, ...);
+ Task DeleteTaskAsync(string taskId, ...);
+ Task ListTasksAsync(ListTasksRequest request, ...);
+}
+```
+
+- **`InMemoryTaskStore`**: `ConcurrentDictionary` with deep clone
+- **`FileTaskStore`** (sample): File-backed store with task JSON files + context indexes
+- **`TaskProjection.Apply`**: Used by `A2AServer` to mutate task state before saving
+- **Atomic subscribe**: Per-task `SemaphoreSlim` in `ChannelEventNotifier` guarantees no events missed between task snapshot and channel registration
+
+### 6. CancelTask Metadata (`752caf0`)
+
+- Add `Metadata` property to `CancelTaskRequest` (spec proto field 3, late addition)
+- Wire `request.Metadata` into `AgentContext` in `CancelTaskAsync`
+
+### 7. FileStoreDemo Sample (`31a57ba`)
+
+- Self-contained demo showing data recovery after server restart
+- Demonstrates `ListTasksAsync` by context filter across restarts
+
+## 8. Store Architecture Evolution
+
+The PR went through an intentional design evolution:
+
+1. **Event sourcing** (commits 8-11): Introduced `IEventStore`/`ITaskEventStore` with 7 methods, versioned event logs, `ChannelEventSubscriber` with catch-up replay
+2. **Analysis**: Studied the A2A spec — it requires mutable task state, not event logs. The Python SDK uses 3 methods. Event sourcing imposed unnecessary complexity on every custom store implementor.
+3. **CRUD store** (commit 12): Replaced with `ITaskStore` (4 methods). Subscribe race condition solved via atomic per-task locking instead of store-level version tracking. Net result: -422 lines.
+
+## Bug Fixes
+
+- **Stale task objects** (`9979994`): Re-fetch task from store after consuming all events
+- **TaskCreatedCount** (`4cf8a59`): Only counts when a genuinely new task is created (store has no existing task)
+- **AutoPersistEvents removed** (`f0bd87e`): Was a footgun — server doesn't function without applying events. Renamed `PersistEventAsync` → `ApplyEventAsync`
+- **Bounded channel deadlock** (`ad6e513`): Run handler concurrently with consumer
+
+## Breaking Changes
+
+| Change | Commit | Mitigation |
+|--------|--------|------------|
+| `ITaskManager` → `IA2ARequestHandler` | `ad6e513` | Rename + update constructor |
+| `TaskManager` → `A2AServer` | `ad6e513` | Use `AddA2AAgent()` DI helper |
+| `ITaskStore` (new interface) | `a7df8a6` | 4 simple CRUD methods |
+| TFM: netstandard2.0/net9.0 dropped | `0ebe07a` | Target net8.0 or net10.0 |
+| `A2AServer` constructor requires `ChannelEventNotifier` | `a7df8a6` | Use DI (automatic) |
+| `AutoPersistEvents` removed | `f0bd87e` | No longer configurable |
+
+## Validation
+
+| Check | Result |
+|-------|--------|
+| `dotnet build` | 0 errors, 0 warnings |
+| `dotnet test` (net8.0 + net10.0) | 1,168 tests pass, 0 failures |
+| TCK mandatory tests | 76/76 pass |
+| FileStoreDemo data recovery | Works correctly |
+| v0.3 backward compatibility | 262 tests pass per TFM |
diff --git a/samples/A2ACli/A2ACli.csproj b/samples/A2ACli/A2ACli.csproj
index 30ec74f9..d62564f4 100644
--- a/samples/A2ACli/A2ACli.csproj
+++ b/samples/A2ACli/A2ACli.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
A2A
A2A.Cli
12
diff --git a/samples/A2ACli/Host/A2ACli.cs b/samples/A2ACli/Host/A2ACli.cs
index d9c6a4f9..07f28ae5 100644
--- a/samples/A2ACli/Host/A2ACli.cs
+++ b/samples/A2ACli/Host/A2ACli.cs
@@ -106,7 +106,7 @@ private static async Task RunCliAsync(
int notificationReceiverPort = notificationReceiverUri.Port;
// Create A2A client
- var client = new A2AClient(new Uri(card.Url));
+ var client = new A2AClient(new Uri(card.SupportedInterfaces.First().Url));
// Create or use provided session ID
string sessionId = session == "0" ? Guid.NewGuid().ToString("N") : session;
@@ -131,7 +131,7 @@ private static async Task RunCliAsync(
if (history && continueLoop)
{
Console.WriteLine("========= history ======== ");
- var taskResponse = await client.GetTaskAsync(taskId, cancellationToken);
+ var taskResponse = await client.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken);
// Display history in a way similar to the Python version
if (taskResponse.History != null)
@@ -141,9 +141,9 @@ private static async Task RunCliAsync(
s_indentOptions));
}
taskResponse?.History?
- .SelectMany(artifact => artifact.Parts.OfType())
+ .SelectMany(msg => msg.Parts.Where(p => p.Text is not null))
.ToList()
- .ForEach(textPart => Console.WriteLine(textPart.Text));
+ .ForEach(part => Console.WriteLine(part.Text));
}
}
}
@@ -179,15 +179,13 @@ private static async Task CompleteTaskAsync(
}
// Create message with text part
- var message = new AgentMessage
+ var message = new Message
{
- Role = MessageRole.User,
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString("N"),
Parts =
[
- new TextPart
- {
- Text = prompt
- }
+ Part.FromText(prompt)
]
};
@@ -199,16 +197,9 @@ private static async Task CompleteTaskAsync(
try
{
byte[] fileBytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
- string fileContent = Convert.ToBase64String(fileBytes);
string fileName = Path.GetFileName(filePath);
- message.Parts.Add(new FilePart
- {
- File = new FileContent(fileContent)
- {
- Name = fileName,
- }
- });
+ message.Parts.Add(Part.FromRaw(fileBytes, filename: fileName));
}
catch (Exception ex)
{
@@ -217,7 +208,7 @@ private static async Task CompleteTaskAsync(
}
// Create payload for the task
- var payload = new MessageSendParams()
+ var payload = new SendMessageRequest()
{
Configuration = new()
{
@@ -229,12 +220,12 @@ private static async Task CompleteTaskAsync(
// Add push notification configuration if enabled
if (usePushNotifications)
{
- payload.Configuration.PushNotification = new PushNotificationConfig
+ payload.Configuration.PushNotificationConfig = new PushNotificationConfig
{
Url = $"http://{notificationReceiverHost}:{notificationReceiverPort}/notify",
- Authentication = new PushNotificationAuthenticationInfo
+ Authentication = new AuthenticationInfo
{
- Schemes = ["bearer"]
+ Scheme = "bearer"
}
};
}
@@ -251,21 +242,22 @@ private static async Task CompleteTaskAsync(
Console.WriteLine($"Send task payload => {JsonSerializer.Serialize(payload, jsonOptions)}");
if (streaming)
{
- await foreach (var result in client.SendMessageStreamingAsync(payload, cancellationToken))
+ await foreach (var result in client.SendStreamingMessageAsync(payload, cancellationToken))
{
Console.WriteLine($"Stream event => {JsonSerializer.Serialize(result, jsonOptions)}");
}
- var taskResult = await client.GetTaskAsync(taskId, cancellationToken);
+ var taskResult = await client.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken);
}
else
{
- agentTask = await client.SendMessageAsync(payload, cancellationToken) as AgentTask;
+ var response = await client.SendMessageAsync(payload, cancellationToken);
+ agentTask = response.Task;
Console.WriteLine($"\n{JsonSerializer.Serialize(agentTask, jsonOptions)}");
agentTask?.Artifacts?
- .SelectMany(artifact => artifact.Parts.OfType())
+ .SelectMany(artifact => artifact.Parts.Where(p => p.Text is not null))
.ToList()
- .ForEach(textPart => Console.WriteLine(textPart.Text));
+ .ForEach(part => Console.WriteLine(part.Text));
}
// If the task requires more input, continue the interaction
diff --git a/samples/AgentClient/AgentClient.csproj b/samples/AgentClient/AgentClient.csproj
index 446db224..434fa1e9 100644
--- a/samples/AgentClient/AgentClient.csproj
+++ b/samples/AgentClient/AgentClient.csproj
@@ -2,7 +2,7 @@
Exe
- net9.0
+ net10.0
enable
$(NoWarn);CA1869;RCS1141
enable
diff --git a/samples/AgentClient/Samples/GetAgentDetailsSample.cs b/samples/AgentClient/Samples/GetAgentDetailsSample.cs
index 72b04ea3..fb703d1b 100644
--- a/samples/AgentClient/Samples/GetAgentDetailsSample.cs
+++ b/samples/AgentClient/Samples/GetAgentDetailsSample.cs
@@ -32,6 +32,11 @@ namespace AgentClient.Samples;
///
internal sealed class GetAgentDetailsSample
{
+ private static readonly JsonSerializerOptions s_indentedOptions = new(A2AJsonUtilities.DefaultOptions)
+ {
+ WriteIndented = true
+ };
+
///
/// Demonstrates how to retrieve agent details using the .
///
@@ -59,9 +64,6 @@ public static async Task RunAsync()
// 4. Display agent details
Console.WriteLine("\nAgent card details:");
- Console.WriteLine(JsonSerializer.Serialize(agentCard, new JsonSerializerOptions(A2AJsonUtilities.DefaultOptions)
- {
- WriteIndented = true
- }));
+ Console.WriteLine(JsonSerializer.Serialize(agentCard, s_indentedOptions));
}
}
diff --git a/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs b/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs
index ea9a8c7c..45485100 100644
--- a/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs
+++ b/samples/AgentClient/Samples/MessageBasedCommunicationSample.cs
@@ -1,5 +1,4 @@
using A2A;
-using System.Net.ServerSentEvents;
namespace AgentClient.Samples;
@@ -57,41 +56,36 @@ public static async Task RunAsync()
AgentCard echoAgentCard = await cardResolver.GetAgentCardAsync();
// 2. Create an A2A client to communicate with the agent using url from the agent card
- A2AClient agentClient = new(new Uri(echoAgentCard.Url));
+ A2AClient agentClient = new(new Uri(echoAgentCard.SupportedInterfaces[0].Url));
// 3. Create a message to send to the agent
- AgentMessage userMessage = new()
+ Message userMessage = new()
{
- Role = MessageRole.User,
+ Role = Role.User,
MessageId = Guid.NewGuid().ToString(),
- Parts = [
- new TextPart
- {
- Text = "Hello from the message-based communication sample! Please echo this message."
- }
- ]
+ Parts = [Part.FromText("Hello from the message-based communication sample! Please echo this message.")]
};
// 4. Send the message using non-streaming API
await SendMessageAsync(agentClient, userMessage);
// 5. Send the message using streaming API
- await SendMessageStreamingAsync(agentClient, userMessage);
+ await SendStreamingMessageAsync(agentClient, userMessage);
}
///
/// Demonstrates non-streaming message communication with an A2A agent.
///
- private static async Task SendMessageAsync(A2AClient agentClient, AgentMessage userMessage)
+ private static async Task SendMessageAsync(A2AClient agentClient, Message userMessage)
{
Console.WriteLine("\nNon-Streaming Message Communication");
- Console.WriteLine($" Sending message via non-streaming API: {((TextPart)userMessage.Parts[0]).Text}");
+ Console.WriteLine($" Sending message via non-streaming API: {userMessage.Parts[0].Text}");
// Send the message and get the response
- AgentMessage agentResponse = (AgentMessage)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage });
+ SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage });
// Display the response
- Console.WriteLine($" Received complete response from agent: {((TextPart)agentResponse.Parts[0]).Text}");
+ Console.WriteLine($" Received complete response from agent: {response.Message!.Parts[0].Text}");
}
///
@@ -100,18 +94,19 @@ private static async Task SendMessageAsync(A2AClient agentClient, AgentMessage u
/// The A2A client for communicating with the agent.
/// The message to send to the agent.
/// A task representing the asynchronous operation.
- private static async Task SendMessageStreamingAsync(A2AClient agentClient, AgentMessage userMessage)
+ private static async Task SendStreamingMessageAsync(A2AClient agentClient, Message userMessage)
{
Console.WriteLine("\nStreaming Message Communication");
- Console.WriteLine($" Sending message via streaming API: {((TextPart)userMessage.Parts[0]).Text}");
+ Console.WriteLine($" Sending message via streaming API: {userMessage.Parts[0].Text}");
// Send the message and get the response as a stream
- await foreach (SseItem sseItem in agentClient.SendMessageStreamingAsync(new MessageSendParams { Message = userMessage }))
+ await foreach (StreamResponse streamResponse in agentClient.SendStreamingMessageAsync(new SendMessageRequest { Message = userMessage }))
{
- AgentMessage agentResponse = (AgentMessage)sseItem.Data;
-
// Display each part of the response as it arrives
- Console.WriteLine($" Received streaming response chunk: {((TextPart)agentResponse.Parts[0]).Text}");
+ if (streamResponse.Message is { } message)
+ {
+ Console.WriteLine($" Received streaming response chunk: {message.Parts[0].Text}");
+ }
}
}
}
\ No newline at end of file
diff --git a/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs b/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs
index 0b3d3acb..1bffef29 100644
--- a/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs
+++ b/samples/AgentClient/Samples/TaskBasedCommunicationSample.cs
@@ -58,7 +58,7 @@ public static async Task RunAsync()
AgentCard echoAgentCard = await cardResolver.GetAgentCardAsync();
// 3. Create an A2A client to communicate with the echotasks agent using the URL from the agent card
- A2AClient agentClient = new(new Uri(echoAgentCard.Url));
+ A2AClient agentClient = new(new Uri(echoAgentCard.SupportedInterfaces[0].Url));
// 4. Demo a short-lived task
await DemoShortLivedTaskAsync(agentClient);
@@ -74,15 +74,16 @@ private static async Task DemoShortLivedTaskAsync(A2AClient agentClient)
{
Console.WriteLine("\nShort-lived Task");
- AgentMessage userMessage = new()
+ Message userMessage = new()
{
- Parts = [new TextPart { Text = "Hello from a short-lived task sample!" }],
- Role = MessageRole.User
+ Parts = [Part.FromText("Hello from a short-lived task sample!")],
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString("N")
};
- Console.WriteLine($" Sending message to the agent: {((TextPart)userMessage.Parts[0]).Text}");
- AgentTask agentResponse = (AgentTask)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage });
- DisplayTaskDetails(agentResponse);
+ Console.WriteLine($" Sending message to the agent: {userMessage.Parts[0].Text}");
+ SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage });
+ DisplayTaskDetails(response.Task!);
}
///
@@ -92,10 +93,11 @@ private static async Task DemoLongRunningTaskAsync(A2AClient agentClient)
{
Console.WriteLine("\nLong-running Task");
- AgentMessage userMessage = new()
+ Message userMessage = new()
{
- Parts = [new TextPart { Text = "Hello from a long-running task sample!" }],
- Role = MessageRole.User,
+ Parts = [Part.FromText("Hello from a long-running task sample!")],
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString("N"),
Metadata = new Dictionary
{
// Tweaking the agent behavior to simulate a long-running task;
@@ -105,18 +107,19 @@ private static async Task DemoLongRunningTaskAsync(A2AClient agentClient)
};
// 1. Create a new task by sending the message to the agent
- Console.WriteLine($" Sending message to the agent: {((TextPart)userMessage.Parts[0]).Text}");
- AgentTask agentResponse = (AgentTask)await agentClient.SendMessageAsync(new MessageSendParams { Message = userMessage });
+ Console.WriteLine($" Sending message to the agent: {userMessage.Parts[0].Text}");
+ SendMessageResponse response = await agentClient.SendMessageAsync(new SendMessageRequest { Message = userMessage });
+ AgentTask agentResponse = response.Task!;
DisplayTaskDetails(agentResponse);
// 2. Retrieve the task
Console.WriteLine($"\n Retrieving the task by ID: {agentResponse.Id}");
- agentResponse = await agentClient.GetTaskAsync(agentResponse.Id);
+ agentResponse = await agentClient.GetTaskAsync(new GetTaskRequest { Id = agentResponse.Id });
DisplayTaskDetails(agentResponse);
// 3. Cancel the task
Console.WriteLine($"\n Cancel the task with ID: {agentResponse.Id}");
- AgentTask cancelledTask = await agentClient.CancelTaskAsync(new TaskIdParams { Id = agentResponse.Id });
+ AgentTask cancelledTask = await agentClient.CancelTaskAsync(new CancelTaskRequest { Id = agentResponse.Id });
DisplayTaskDetails(cancelledTask);
}
@@ -125,6 +128,6 @@ private static void DisplayTaskDetails(AgentTask agentResponse)
Console.WriteLine(" Received task details:");
Console.WriteLine($" ID: {agentResponse.Id}");
Console.WriteLine($" Status: {agentResponse.Status.State}");
- Console.WriteLine($" Artifact: {(agentResponse.Artifacts?[0].Parts?[0] as TextPart)?.Text}");
+ Console.WriteLine($" Artifact: {agentResponse.Artifacts?[0].Parts?[0].Text}");
}
}
diff --git a/samples/AgentServer/AgentServer.csproj b/samples/AgentServer/AgentServer.csproj
index 63f6d317..af19b4ca 100644
--- a/samples/AgentServer/AgentServer.csproj
+++ b/samples/AgentServer/AgentServer.csproj
@@ -1,7 +1,7 @@
- net9.0
+ net10.0
enable
enable
diff --git a/samples/AgentServer/EchoAgent.cs b/samples/AgentServer/EchoAgent.cs
index 6729ef93..663ebedc 100644
--- a/samples/AgentServer/EchoAgent.cs
+++ b/samples/AgentServer/EchoAgent.cs
@@ -1,62 +1,46 @@
-using A2A;
-
-namespace AgentServer;
-
-public class EchoAgent
-{
- public void Attach(ITaskManager taskManager)
- {
- taskManager.OnMessageReceived = ProcessMessageAsync;
- taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- private Task ProcessMessageAsync(MessageSendParams messageSendParams, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- // Process the message
- var messageText = messageSendParams.Message.Parts.OfType().First().Text;
-
- // Create and return an artifact
- var message = new AgentMessage()
- {
- Role = MessageRole.Agent,
- MessageId = Guid.NewGuid().ToString(),
- ContextId = messageSendParams.Message.ContextId,
- Parts = [new TextPart() {
- Text = $"Echo: {messageText}"
- }]
- };
-
- return Task.FromResult(message);
- }
-
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities()
- {
- Streaming = true,
- PushNotifications = false,
- };
-
- return Task.FromResult(new AgentCard()
- {
- Name = "Echo Agent",
- Description = "Agent which will echo every message it receives.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [],
- });
- }
+using A2A;
+
+namespace AgentServer;
+
+public sealed class EchoAgent : IAgentHandler
+{
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var responder = new MessageResponder(eventQueue, context.ContextId);
+ await responder.ReplyAsync($"Echo: {context.UserText}", cancellationToken);
+ }
+
+ public static AgentCard GetAgentCard(string agentUrl) =>
+ new()
+ {
+ Name = "Echo Agent",
+ Description = "Agent which will echo every message it receives.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = agentUrl,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }
+ ],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities
+ {
+ Streaming = true,
+ PushNotifications = false,
+ },
+ Skills =
+ [
+ new AgentSkill
+ {
+ Id = "echo",
+ Name = "Echo",
+ Description = "Echoes back the user message.",
+ Tags = ["echo", "test"],
+ }
+ ],
+ };
}
\ No newline at end of file
diff --git a/samples/AgentServer/EchoAgentWithTasks.cs b/samples/AgentServer/EchoAgentWithTasks.cs
index 2e83bfcd..872adb7a 100644
--- a/samples/AgentServer/EchoAgentWithTasks.cs
+++ b/samples/AgentServer/EchoAgentWithTasks.cs
@@ -1,82 +1,83 @@
-using A2A;
-using System.Text.Json;
-
-namespace AgentServer;
-
-public class EchoAgentWithTasks
-{
- private ITaskManager? _taskManager;
-
- public void Attach(ITaskManager taskManager)
- {
- _taskManager = taskManager;
- taskManager.OnTaskCreated = ProcessMessageAsync;
- taskManager.OnTaskUpdated = ProcessMessageAsync;
- taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- private async Task ProcessMessageAsync(AgentTask task, CancellationToken cancellationToken)
- {
- cancellationToken.ThrowIfCancellationRequested();
-
- // Process the message
- var lastMessage = task.History!.Last();
- var messageText = lastMessage.Parts.OfType().First().Text;
-
- // Check for target-state metadata to determine task behavior
- TaskState targetState = GetTargetStateFromMetadata(lastMessage.Metadata) ?? TaskState.Completed;
-
- // This is a short-lived task - complete it immediately
- await _taskManager!.ReturnArtifactAsync(task.Id, new Artifact()
- {
- Parts = [new TextPart() {
- Text = $"Echo: {messageText}"
- }]
- }, cancellationToken);
-
- await _taskManager!.UpdateStatusAsync(
- task.Id,
- status: targetState,
- final: targetState is TaskState.Completed or TaskState.Canceled or TaskState.Failed or TaskState.Rejected,
- cancellationToken: cancellationToken);
- }
-
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities()
- {
- Streaming = true,
- PushNotifications = false,
- };
-
- return Task.FromResult(new AgentCard()
- {
- Name = "Echo Agent",
- Description = "Agent which will echo every message it receives.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [],
- });
- }
-
- private static TaskState? GetTargetStateFromMetadata(Dictionary? metadata)
- {
- if (metadata?.TryGetValue("task-target-state", out var targetStateElement) == true)
- {
- if (Enum.TryParse(targetStateElement.GetString(), true, out var state))
- {
- return state;
- }
- }
-
- return null;
- }
+using A2A;
+using System.Text.Json;
+
+namespace AgentServer;
+
+public sealed class EchoAgentWithTasks : IAgentHandler
+{
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var targetState = GetTargetStateFromMetadata(context.Message.Metadata);
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+
+ await updater.SubmitAsync(cancellationToken);
+ await updater.AddArtifactAsync(
+ [Part.FromText($"Echo: {context.UserText}")], cancellationToken: cancellationToken);
+
+ // Transition to the target state (defaults to Completed)
+ switch (targetState)
+ {
+ case TaskState.Failed:
+ await updater.FailAsync(cancellationToken: cancellationToken);
+ break;
+ case TaskState.Canceled:
+ await updater.CancelAsync(cancellationToken);
+ break;
+ case TaskState.InputRequired:
+ await updater.RequireInputAsync(
+ new Message { Role = Role.Agent, MessageId = Guid.NewGuid().ToString("N"), Parts = [Part.FromText("Need input")] },
+ cancellationToken);
+ break;
+ default:
+ await updater.CompleteAsync(cancellationToken: cancellationToken);
+ break;
+ }
+ }
+
+ public static AgentCard GetAgentCard(string agentUrl) =>
+ new()
+ {
+ Name = "Echo Agent",
+ Description = "Agent which will echo every message it receives.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = agentUrl,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }
+ ],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities
+ {
+ Streaming = true,
+ PushNotifications = false,
+ },
+ Skills =
+ [
+ new AgentSkill
+ {
+ Id = "echo",
+ Name = "Echo",
+ Description = "Echoes back the user message with task tracking.",
+ Tags = ["echo", "test"],
+ }
+ ],
+ };
+
+ private static TaskState? GetTargetStateFromMetadata(Dictionary? metadata)
+ {
+ if (metadata?.TryGetValue("task-target-state", out var targetStateElement) == true)
+ {
+ if (Enum.TryParse(targetStateElement.GetString(), true, out var state))
+ {
+ return state;
+ }
+ }
+
+ return null;
+ }
}
\ No newline at end of file
diff --git a/samples/AgentServer/FileTaskStore.cs b/samples/AgentServer/FileTaskStore.cs
new file mode 100644
index 00000000..9a2dcc08
--- /dev/null
+++ b/samples/AgentServer/FileTaskStore.cs
@@ -0,0 +1,218 @@
+using A2A;
+using System.Collections.Concurrent;
+using System.Text.Json;
+
+namespace AgentServer;
+
+///
+/// File-backed task store with per-task JSON files and context index files
+/// for efficient .
+///
+/// Storage layout:
+///
+/// {baseDir}/
+/// tasks/{taskId}.json — materialized AgentTask snapshot
+/// indexes/context_{id}.idx — newline-delimited task IDs per context
+///
+///
+///
+public sealed class FileTaskStore : ITaskStore
+{
+ private readonly string _tasksDir;
+ private readonly string _indexesDir;
+
+ // Compact JSON for storage
+ private static readonly JsonSerializerOptions s_storageOptions = new(A2AJsonUtilities.DefaultOptions)
+ {
+ WriteIndented = false,
+ };
+
+ // Per-task lock to serialize writes (prevents interleaved writes to the same file)
+ private readonly ConcurrentDictionary _taskLocks = new();
+
+ public FileTaskStore(string baseDir)
+ {
+ _tasksDir = Path.Combine(baseDir, "tasks");
+ _indexesDir = Path.Combine(baseDir, "indexes");
+
+ Directory.CreateDirectory(_tasksDir);
+ Directory.CreateDirectory(_indexesDir);
+ }
+
+ ///
+ public async Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
+ {
+ return await ReadTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task SaveTaskAsync(string taskId, AgentTask task, CancellationToken cancellationToken = default)
+ {
+ var taskLock = _taskLocks.GetOrAdd(taskId, _ => new SemaphoreSlim(1, 1));
+ await taskLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await WriteTaskAsync(taskId, task, cancellationToken).ConfigureAwait(false);
+
+ // Update context index on save (idempotent)
+ if (!string.IsNullOrEmpty(task.ContextId))
+ {
+ await AppendToIndexAsync("context", task.ContextId, taskId, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ taskLock.Release();
+ }
+ }
+
+ ///
+ public Task DeleteTaskAsync(string taskId, CancellationToken cancellationToken = default)
+ {
+ var path = GetTaskFilePath(taskId);
+ if (File.Exists(path))
+ File.Delete(path);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task ListTasksAsync(ListTasksRequest request,
+ CancellationToken cancellationToken = default)
+ {
+ // Step 1: Find candidate task IDs from index files
+ var taskIds = await GetTaskIdsForQueryAsync(request, cancellationToken).ConfigureAwait(false);
+
+ // Step 2: Load tasks for candidates
+ var tasks = new List();
+ foreach (var taskId in taskIds)
+ {
+ var task = await ReadTaskAsync(taskId, cancellationToken).ConfigureAwait(false);
+ if (task is null) continue;
+
+ // Apply filters that aren't covered by the index
+ if (request.Status is { } statusFilter && task.Status.State != statusFilter)
+ continue;
+ if (request.StatusTimestampAfter is not null &&
+ (task.Status.Timestamp is null || task.Status.Timestamp <= request.StatusTimestampAfter))
+ continue;
+
+ tasks.Add(task);
+ }
+
+ // Step 3: Sort, paginate, trim
+ tasks.Sort((a, b) =>
+ (b.Status.Timestamp ?? DateTimeOffset.MinValue)
+ .CompareTo(a.Status.Timestamp ?? DateTimeOffset.MinValue));
+
+ var totalSize = tasks.Count;
+
+ int startIndex = 0;
+ if (!string.IsNullOrEmpty(request.PageToken))
+ {
+ if (!int.TryParse(request.PageToken, out var offset) || offset < 0)
+ throw new A2AException($"Invalid pageToken: {request.PageToken}", A2AErrorCode.InvalidParams);
+ startIndex = offset;
+ }
+
+ var pageSize = request.PageSize ?? 50;
+ var page = tasks.Skip(startIndex).Take(pageSize).ToList();
+
+ // Trim history/artifacts per request
+ foreach (var task in page)
+ {
+ if (request.HistoryLength is { } historyLength)
+ {
+ if (historyLength == 0)
+ task.History = null;
+ else if (task.History is { Count: > 0 })
+ task.History = task.History.TakeLast(historyLength).ToList();
+ }
+
+ if (request.IncludeArtifacts is not true)
+ task.Artifacts = null;
+ }
+
+ var nextIndex = startIndex + page.Count;
+ return new ListTasksResponse
+ {
+ Tasks = page,
+ NextPageToken = nextIndex < totalSize
+ ? nextIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ : string.Empty,
+ PageSize = page.Count,
+ TotalSize = totalSize,
+ };
+ }
+
+ // ─── File I/O Helpers ───
+
+ private string GetTaskFilePath(string taskId) => Path.Combine(_tasksDir, $"{taskId}.json");
+ private string GetIndexFilePath(string prefix, string id) => Path.Combine(_indexesDir, $"{prefix}_{id}.idx");
+
+ private async Task ReadTaskAsync(string taskId, CancellationToken ct)
+ {
+ var path = GetTaskFilePath(taskId);
+ if (!File.Exists(path)) return null;
+
+ await using var stream = File.OpenRead(path);
+ return await JsonSerializer.DeserializeAsync(stream, A2AJsonUtilities.DefaultOptions, ct)
+ .ConfigureAwait(false);
+ }
+
+ private async Task WriteTaskAsync(string taskId, AgentTask task, CancellationToken ct)
+ {
+ var path = GetTaskFilePath(taskId);
+ var tempPath = path + ".tmp";
+
+ await using (var stream = File.Create(tempPath))
+ {
+ await JsonSerializer.SerializeAsync(stream, task, A2AJsonUtilities.DefaultOptions, ct)
+ .ConfigureAwait(false);
+ }
+
+ File.Move(tempPath, path, overwrite: true);
+ }
+
+ private async Task AppendToIndexAsync(string prefix, string id, string taskId, CancellationToken ct)
+ {
+ if (string.IsNullOrEmpty(id)) return;
+
+ var indexPath = GetIndexFilePath(prefix, id);
+
+ // Check if task is already in the index (avoid duplicates)
+ if (File.Exists(indexPath))
+ {
+ var existing = await File.ReadAllTextAsync(indexPath, ct).ConfigureAwait(false);
+ if (existing.Contains(taskId, StringComparison.Ordinal))
+ return;
+ }
+
+ await File.AppendAllTextAsync(indexPath, taskId + Environment.NewLine, ct).ConfigureAwait(false);
+ }
+
+ private async Task> GetTaskIdsForQueryAsync(
+ ListTasksRequest request, CancellationToken ct)
+ {
+ // Use the most specific index available
+ if (!string.IsNullOrEmpty(request.ContextId))
+ return await ReadIndexAsync("context", request.ContextId, ct).ConfigureAwait(false);
+
+ // No index match — scan all task files
+ return Directory.GetFiles(_tasksDir, "*.json")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(name => name is not null)
+ .Cast()
+ .ToList();
+ }
+
+ private async Task> ReadIndexAsync(string prefix, string id, CancellationToken ct)
+ {
+ var path = GetIndexFilePath(prefix, id);
+ if (!File.Exists(path)) return [];
+
+ var content = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
+ return content.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
+ }
+}
diff --git a/samples/AgentServer/Program.cs b/samples/AgentServer/Program.cs
index fa104348..33a9cc0a 100644
--- a/samples/AgentServer/Program.cs
+++ b/samples/AgentServer/Program.cs
@@ -1,93 +1,100 @@
-using A2A;
-using A2A.AspNetCore;
-using AgentServer;
-using OpenTelemetry.Resources;
-using OpenTelemetry.Trace;
-
-var builder = WebApplication.CreateBuilder(args);
-
-// Configure OpenTelemetry
-builder.Services.AddOpenTelemetry()
- .ConfigureResource(resource => resource.AddService("A2AAgentServer"))
- .WithTracing(tracing => tracing
- .AddSource(TaskManager.ActivitySource.Name)
- .AddSource(A2AJsonRpcProcessor.ActivitySource.Name)
- .AddSource(ResearcherAgent.ActivitySource.Name)
- .AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddConsoleExporter()
- .AddOtlpExporter(options =>
- {
- options.Endpoint = new Uri("http://localhost:4317");
- options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
- })
- );
-
-var app = builder.Build();
-
-app.UseHttpsRedirection();
-
-// Add health endpoint
-app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }));
-
-// Get the agent type from command line arguments
-var agentType = GetAgentTypeFromArgs(args);
-
-// Create and register the specified agent
-var taskManager = new TaskManager();
-
-switch (agentType.ToLowerInvariant())
-{
- case "echo":
- var echoAgent = new EchoAgent();
- echoAgent.Attach(taskManager);
- app.MapA2A(taskManager, "/echo");
- app.MapWellKnownAgentCard(taskManager, "/echo");
- app.MapHttpA2A(taskManager, "/echo");
- break;
-
- case "echotasks":
- var echoAgentWithTasks = new EchoAgentWithTasks();
- echoAgentWithTasks.Attach(taskManager);
- app.MapA2A(taskManager, "/echotasks");
- app.MapWellKnownAgentCard(taskManager, "/echotasks");
- app.MapHttpA2A(taskManager, "/echotasks");
- break;
-
- case "researcher":
- var researcherAgent = new ResearcherAgent();
- researcherAgent.Attach(taskManager);
- app.MapA2A(taskManager, "/researcher");
- app.MapWellKnownAgentCard(taskManager, "/researcher");
- break;
-
- case "speccompliance":
- var specComplianceAgent = new SpecComplianceAgent();
- specComplianceAgent.Attach(taskManager);
- app.MapA2A(taskManager, "/speccompliance");
- app.MapWellKnownAgentCard(taskManager, "/speccompliance");
- break;
-
- default:
- Console.WriteLine($"Unknown agent type: {agentType}");
- Environment.Exit(1);
- return;
-}
-
-app.Run();
-
-static string GetAgentTypeFromArgs(string[] args)
-{
- // Look for --agent parameter
- for (int i = 0; i < args.Length - 1; i++)
- {
- if (args[i] == "--agent" || args[i] == "-a")
- {
- return args[i + 1];
- }
- }
-
- // Default to echo if no agent specified
- Console.WriteLine("No agent specified. Use --agent or -a parameter to specify agent type (echo, echotasks, researcher, speccompliance). Defaulting to 'echo'.");
- return "echo";
+using A2A;
+using A2A.AspNetCore;
+using AgentServer;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Get the agent type and store type from command line arguments
+var agentType = GetArgValue(args, "--agent", "-a") ?? "echo";
+var storeType = GetArgValue(args, "--store", "-s");
+var baseUrl = "http://localhost:5048";
+
+// Register file-backed task store if requested (before AddA2AAgent so TryAddSingleton picks it up)
+if (storeType?.Equals("file", StringComparison.OrdinalIgnoreCase) == true)
+{
+ var dataDir = GetArgValue(args, "--data-dir", "-d") ?? Path.Combine(Directory.GetCurrentDirectory(), "a2a-data");
+ Console.WriteLine($"Using FileTaskStore at: {dataDir}");
+ builder.Services.AddSingleton(sp =>
+ new FileTaskStore(dataDir));
+}
+
+// Register the appropriate agent via DI
+switch (agentType.ToLowerInvariant())
+{
+ case "echo":
+ builder.Services.AddA2AAgent(EchoAgent.GetAgentCard($"{baseUrl}/echo"));
+ break;
+
+ case "echotasks":
+ builder.Services.AddA2AAgent(EchoAgentWithTasks.GetAgentCard($"{baseUrl}/echotasks"));
+ break;
+
+ case "researcher":
+ builder.Services.AddA2AAgent(ResearcherAgent.GetAgentCard($"{baseUrl}/researcher"));
+ break;
+
+ case "speccompliance":
+ builder.Services.AddA2AAgent(SpecComplianceAgent.GetAgentCard($"{baseUrl}/speccompliance"));
+ break;
+
+ default:
+ Console.WriteLine($"Unknown agent type: {agentType}");
+ Environment.Exit(1);
+ return;
+}
+
+// Configure OpenTelemetry
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService("A2AAgentServer"))
+ .WithTracing(tracing => tracing
+ .AddSource("A2A")
+ .AddSource("A2A.AspNetCore")
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddConsoleExporter()
+ .AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri("http://localhost:4317");
+ options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
+ })
+ );
+
+var app = builder.Build();
+
+app.UseHttpsRedirection();
+
+// Add health endpoint
+app.MapGet("/health", () => Results.Ok(new { Status = "Healthy", Timestamp = DateTimeOffset.UtcNow }));
+
+// Map A2A endpoints using DI-registered services
+var path = agentType.ToLowerInvariant() switch
+{
+ "echo" => "/echo",
+ "echotasks" => "/echotasks",
+ "researcher" => "/researcher",
+ "speccompliance" => "/speccompliance",
+ _ => "/agent",
+};
+
+app.MapA2A(path);
+
+// For spec compliance, also map at root for well-known agent card discovery
+if (agentType.Equals("speccompliance", StringComparison.OrdinalIgnoreCase))
+{
+ var card = app.Services.GetRequiredService();
+ app.MapWellKnownAgentCard(card);
+}
+
+app.Run();
+
+static string? GetArgValue(string[] args, string longName, string shortName)
+{
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == longName || args[i] == shortName)
+ return args[i + 1];
+ }
+ return null;
}
\ No newline at end of file
diff --git a/samples/AgentServer/ResearcherAgent.cs b/samples/AgentServer/ResearcherAgent.cs
index 6b5408f6..de9403a8 100644
--- a/samples/AgentServer/ResearcherAgent.cs
+++ b/samples/AgentServer/ResearcherAgent.cs
@@ -1,171 +1,98 @@
-using System.Diagnostics;
-
-namespace A2A;
-
-public class ResearcherAgent
-{
- private ITaskManager? _taskManager;
- private readonly Dictionary _agentStates = [];
- public static readonly ActivitySource ActivitySource = new("A2A.ResearcherAgent", "1.0.0");
-
- private enum AgentState
- {
- Planning,
- WaitingForFeedbackOnPlan,
- Researching
- }
-
- public void Attach(ITaskManager taskManager)
- {
- _taskManager = taskManager;
- _taskManager.OnTaskCreated = async (task, cancellationToken) =>
- {
- // Initialize the agent state for the task
- _agentStates[task.Id] = AgentState.Planning;
- // Ignore other content in the task, just assume it is a text message.
- var message = ((TextPart?)task.History?.Last()?.Parts?.FirstOrDefault())?.Text ?? string.Empty;
- await InvokeAsync(task.Id, message, cancellationToken);
- };
- _taskManager.OnTaskUpdated = async (task, cancellationToken) =>
- {
- // Note that the updated callback is helpful to know not to initialize the agent state again.
- var message = ((TextPart?)task.History?.Last()?.Parts?.FirstOrDefault())?.Text ?? string.Empty;
- await InvokeAsync(task.Id, message, cancellationToken);
- };
- _taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- // This is the main entry point for the agent. It is called when a task is created or updated.
- // It probably should have a cancellation token to enable the process to be cancelled.
- public async Task InvokeAsync(string taskId, string message, CancellationToken cancellationToken)
- {
- if (_taskManager == null)
- {
- throw new InvalidOperationException("TaskManager is not attached.");
- }
-
- using var activity = ActivitySource.StartActivity("Invoke", ActivityKind.Server);
- activity?.SetTag("task.id", taskId);
- activity?.SetTag("message", message);
- activity?.SetTag("state", _agentStates[taskId].ToString());
-
- switch (_agentStates[taskId])
- {
- case AgentState.Planning:
- await DoPlanningAsync(taskId, message, cancellationToken);
- await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage()
- {
- Parts = [new TextPart() { Text = "When ready say go ahead" }],
- },
- cancellationToken: cancellationToken);
- break;
- case AgentState.WaitingForFeedbackOnPlan:
- if (message == "go ahead") // Dumb check for now to avoid using an LLM
- {
- await DoResearchAsync(taskId, message, cancellationToken);
- }
- else
- {
- // Take the message and redo planning
- await DoPlanningAsync(taskId, message, cancellationToken);
- await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage()
- {
- Parts = [new TextPart() { Text = "When ready say go ahead" }],
- },
- cancellationToken: cancellationToken);
- }
- break;
- case AgentState.Researching:
- await DoResearchAsync(taskId, message, cancellationToken);
- break;
- }
- }
-
- private async Task DoResearchAsync(string taskId, string message, CancellationToken cancellationToken)
- {
- if (_taskManager == null)
- {
- throw new InvalidOperationException("TaskManager is not attached.");
- }
-
- using var activity = ActivitySource.StartActivity("DoResearch", ActivityKind.Server);
- activity?.SetTag("task.id", taskId);
- activity?.SetTag("message", message);
-
- _agentStates[taskId] = AgentState.Researching;
- await _taskManager.UpdateStatusAsync(taskId, TaskState.Working, cancellationToken: cancellationToken);
-
- await _taskManager.ReturnArtifactAsync(
- taskId,
- new Artifact()
- {
- Parts = [new TextPart() { Text = $"{message} received." }],
- },
- cancellationToken);
-
- await _taskManager.UpdateStatusAsync(taskId, TaskState.Completed, new AgentMessage()
- {
- Parts = [new TextPart() { Text = "Task completed successfully" }],
- },
- cancellationToken: cancellationToken);
- }
- private async Task DoPlanningAsync(string taskId, string message, CancellationToken cancellationToken)
- {
- if (_taskManager == null)
- {
- throw new InvalidOperationException("TaskManager is not attached.");
- }
-
- using var activity = ActivitySource.StartActivity("DoPlanning", ActivityKind.Server);
- activity?.SetTag("task.id", taskId);
- activity?.SetTag("message", message);
-
- // Task should be in status Submitted
- // Simulate being in a queue for a while
- await Task.Delay(1000, cancellationToken);
-
- // Simulate processing the task
- await _taskManager.UpdateStatusAsync(taskId, TaskState.Working, cancellationToken: cancellationToken);
-
- await _taskManager.ReturnArtifactAsync(
- taskId,
- new Artifact()
- {
- Parts = [new TextPart() { Text = $"{message} received." }],
- },
- cancellationToken);
-
- await _taskManager.UpdateStatusAsync(taskId, TaskState.InputRequired, new AgentMessage()
- {
- Parts = [new TextPart() { Text = "When ready say go ahead" }],
- },
- cancellationToken: cancellationToken);
- _agentStates[taskId] = AgentState.WaitingForFeedbackOnPlan;
- }
-
- private Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities()
- {
- Streaming = true,
- PushNotifications = false,
- };
-
- return Task.FromResult(new AgentCard()
- {
- Name = "Researcher Agent",
- Description = "Agent which conducts research.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [],
- });
- }
+using A2A;
+
+namespace AgentServer;
+
+public sealed class ResearcherAgent : IAgentHandler
+{
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+
+ if (!context.IsContinuation)
+ {
+ // New task: planning phase — ask for confirmation
+ await updater.SubmitAsync(cancellationToken);
+ await updater.AddArtifactAsync(
+ [Part.FromText($"{context.UserText} received.")],
+ cancellationToken: cancellationToken);
+
+ await Task.Delay(500, cancellationToken);
+
+ await updater.RequireInputAsync(new Message
+ {
+ Role = Role.Agent,
+ MessageId = Guid.NewGuid().ToString("N"),
+ ContextId = updater.ContextId,
+ Parts = [Part.FromText("When ready say go ahead")],
+ }, cancellationToken);
+ return;
+ }
+
+ // Continuation
+ if (context.UserText == "go ahead")
+ {
+ // Research phase
+ await updater.StartWorkAsync(cancellationToken: cancellationToken);
+ await updater.AddArtifactAsync(
+ [Part.FromText($"{context.UserText} received.")],
+ cancellationToken: cancellationToken);
+ await updater.CompleteAsync(
+ new Message
+ {
+ Role = Role.Agent,
+ MessageId = Guid.NewGuid().ToString("N"),
+ Parts = [Part.FromText("Task completed successfully")],
+ },
+ cancellationToken);
+ }
+ else
+ {
+ // Re-plan — ask again
+ await Task.Delay(500, cancellationToken);
+ await updater.AddArtifactAsync(
+ [Part.FromText($"{context.UserText} received.")],
+ cancellationToken: cancellationToken);
+ await updater.RequireInputAsync(new Message
+ {
+ Role = Role.Agent,
+ MessageId = Guid.NewGuid().ToString("N"),
+ ContextId = updater.ContextId,
+ Parts = [Part.FromText("When ready say go ahead")],
+ }, cancellationToken);
+ }
+ }
+
+ public static AgentCard GetAgentCard(string agentUrl) =>
+ new()
+ {
+ Name = "Researcher Agent",
+ Description = "Agent which conducts research.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = agentUrl,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }
+ ],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities
+ {
+ Streaming = true,
+ PushNotifications = false,
+ },
+ Skills =
+ [
+ new AgentSkill
+ {
+ Id = "research",
+ Name = "Research",
+ Description = "Conducts research on a given topic.",
+ Tags = ["research", "planning"],
+ }
+ ],
+ };
}
\ No newline at end of file
diff --git a/samples/AgentServer/SpecComplianceAgent.cs b/samples/AgentServer/SpecComplianceAgent.cs
index 9aacf602..b70a4bc8 100644
--- a/samples/AgentServer/SpecComplianceAgent.cs
+++ b/samples/AgentServer/SpecComplianceAgent.cs
@@ -1,67 +1,100 @@
-using A2A;
-
-namespace AgentServer;
-
-public class SpecComplianceAgent
-{
- private ITaskManager? _taskManager;
-
- public void Attach(ITaskManager taskManager)
- {
- taskManager.OnAgentCardQuery = GetAgentCard;
- taskManager.OnTaskCreated = OnTaskCreatedAsync;
- taskManager.OnTaskUpdated = OnTaskUpdatedAsync;
- _taskManager = taskManager;
- }
-
- private async Task OnTaskCreatedAsync(AgentTask task, CancellationToken cancellationToken)
- {
- // A temporary solution to prevent the compliance test at https://github.com/a2aproject/a2a-tck/blob/main/tests/optional/capabilities/test_streaming_methods.py from hanging.
- // It can be removed after the issue https://github.com/a2aproject/a2a-dotnet/issues/97 is resolved.
- if (task.History?.Any(m => m.MessageId.StartsWith("test-stream-message-id", StringComparison.InvariantCulture)) ?? false)
- {
- await _taskManager!.UpdateStatusAsync(
- task.Id,
- status: TaskState.Completed,
- final: true,
- cancellationToken: cancellationToken);
- }
- }
-
- private async Task OnTaskUpdatedAsync(AgentTask task, CancellationToken cancellationToken)
- {
- if (task.Status.State is TaskState.Submitted && task.History?.Count > 0)
- {
- // The spec does not specify that a task state must be updated when a message is sent,
- // but the tck tests expect the task to be in Working/Input-required or Completed state after a message is sent:
- // https://github.com/a2aproject/a2a-tck/blob/22f7c191d85f2d4ff2f4564da5d8691944bb7ffd/tests/optional/quality/test_task_state_quality.py#L129
- await _taskManager!.UpdateStatusAsync(task.Id, TaskState.Working, cancellationToken: cancellationToken);
- }
- }
-
- private Task GetAgentCard(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities()
- {
- Streaming = true,
- PushNotifications = false,
- };
-
- return Task.FromResult(new AgentCard()
- {
- Name = "A2A Specification Compliance Agent",
- Description = "Agent to run A2A specification compliance tests.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [],
- });
- }
-}
+using A2A;
+
+namespace AgentServer;
+
+public sealed class SpecComplianceAgent : IAgentHandler
+{
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+
+ // Echo the user's message parts back as an agent reply
+ var replyParts = context.Message.Parts?.ToList() ?? [Part.FromText("")];
+
+ if (!context.IsContinuation)
+ {
+ // New task: Submit, then echo back with Working status
+ var agentReply = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.Agent,
+ TaskId = context.TaskId,
+ ContextId = updater.ContextId,
+ Parts = replyParts,
+ };
+
+ // Emit initial task
+ await eventQueue.EnqueueTaskAsync(new AgentTask
+ {
+ Id = context.TaskId,
+ ContextId = updater.ContextId,
+ Status = new A2A.TaskStatus
+ {
+ State = TaskState.Working,
+ Timestamp = DateTimeOffset.UtcNow,
+ },
+ History = [context.Message, agentReply],
+ }, cancellationToken);
+ }
+ else
+ {
+ // Continuation: echo the parts back
+ var agentReply = new Message
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = Role.Agent,
+ TaskId = context.TaskId,
+ ContextId = updater.ContextId,
+ Parts = replyParts,
+ };
+
+ // Return updated task with the reply added to history
+ var history = context.Task!.History?.ToList() ?? [];
+ history.Add(context.Message);
+ history.Add(agentReply);
+
+ await eventQueue.EnqueueTaskAsync(new AgentTask
+ {
+ Id = context.TaskId,
+ ContextId = updater.ContextId,
+ Status = context.Task.Status,
+ History = history,
+ Artifacts = context.Task.Artifacts,
+ }, cancellationToken);
+ }
+ }
+
+ public static AgentCard GetAgentCard(string agentUrl) =>
+ new()
+ {
+ Name = "A2A Specification Compliance Agent",
+ Description = "Agent for A2A specification compliance verification.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = agentUrl,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }
+ ],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = new AgentCapabilities
+ {
+ Streaming = true,
+ PushNotifications = false,
+ },
+ Skills =
+ [
+ new AgentSkill
+ {
+ Id = "echo",
+ Name = "Echo",
+ Description = "Echoes back user messages for specification compliance verification.",
+ Tags = ["echo", "a2a", "compliance"],
+ }
+ ],
+ };
+}
diff --git a/samples/FileStoreDemo/FileStoreDemo.csproj b/samples/FileStoreDemo/FileStoreDemo.csproj
new file mode 100644
index 00000000..9dd44379
--- /dev/null
+++ b/samples/FileStoreDemo/FileStoreDemo.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/FileStoreDemo/Program.cs b/samples/FileStoreDemo/Program.cs
new file mode 100644
index 00000000..1caab531
--- /dev/null
+++ b/samples/FileStoreDemo/Program.cs
@@ -0,0 +1,261 @@
+// FileStoreDemo: Demonstrates file-backed task store with data recovery after server restart.
+//
+// This demo:
+// 1. Starts an A2A server with FileTaskStore (persists to ./demo-data/)
+// 2. Sends messages that create tasks with artifacts
+// 3. Lists tasks — shows they exist
+// 4. Stops the server (simulating a crash/restart)
+// 5. Starts a NEW server with FileTaskStore pointing to the same directory
+// 6. Lists tasks again — shows data survived the restart
+// 7. Retrieves individual task details — full history and artifacts preserved
+//
+// Usage: dotnet run
+
+using A2A;
+using A2A.AspNetCore;
+using AgentServer;
+
+var baseUrl = "http://localhost:5099";
+var agentPath = "/demo";
+var dataDir = Path.Combine(Directory.GetCurrentDirectory(), "demo-data");
+
+// Clean up from previous runs
+if (Directory.Exists(dataDir))
+ Directory.Delete(dataDir, recursive: true);
+
+Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
+Console.WriteLine("║ FileTaskStore Demo — Data Recovery After Restart ║");
+Console.WriteLine("╚══════════════════════════════════════════════════════════════╝");
+Console.WriteLine();
+
+// ───── Phase 1: Start server, create tasks ─────
+
+Console.WriteLine("▶ Phase 1: Starting server with FileTaskStore...");
+var (host1, client) = await StartServerAsync(baseUrl, agentPath, dataDir);
+
+Console.WriteLine(" Sending 3 messages to create tasks...");
+
+var task1Response = await client.SendMessageAsync(new SendMessageRequest
+{
+ Message = new Message
+ {
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString(),
+ ContextId = "project-alpha",
+ Parts = [Part.FromText("Analyze the quarterly report for Q4 2025")],
+ }
+});
+var task1Id = task1Response.Task?.Id ?? task1Response.Message?.TaskId ?? "(no task)";
+Console.WriteLine($" ✓ Task 1 created: {task1Id}");
+
+var task2Response = await client.SendMessageAsync(new SendMessageRequest
+{
+ Message = new Message
+ {
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString(),
+ ContextId = "project-alpha",
+ Parts = [Part.FromText("Summarize the key findings from the analysis")],
+ }
+});
+var task2Id = task2Response.Task?.Id ?? task2Response.Message?.TaskId ?? "(no task)";
+Console.WriteLine($" ✓ Task 2 created: {task2Id}");
+
+var task3Response = await client.SendMessageAsync(new SendMessageRequest
+{
+ Message = new Message
+ {
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString(),
+ ContextId = "project-beta",
+ Parts = [Part.FromText("Draft a proposal for the new initiative")],
+ }
+});
+var task3Id = task3Response.Task?.Id ?? task3Response.Message?.TaskId ?? "(no task)";
+Console.WriteLine($" ✓ Task 3 created: {task3Id}");
+
+// List tasks before restart
+Console.WriteLine();
+Console.WriteLine(" Listing all tasks before restart:");
+var listBefore = await client.ListTasksAsync(new ListTasksRequest());
+Console.WriteLine($" Found {listBefore.Tasks?.Count ?? 0} tasks (totalSize: {listBefore.TotalSize})");
+foreach (var task in listBefore.Tasks ?? [])
+{
+ Console.WriteLine($" - {task.Id}: status={task.Status.State}, context={task.ContextId}");
+}
+
+// List with context filter
+Console.WriteLine();
+Console.WriteLine(" Listing tasks for context 'project-alpha':");
+var alphaTasksBefore = await client.ListTasksAsync(new ListTasksRequest { ContextId = "project-alpha" });
+Console.WriteLine($" Found {alphaTasksBefore.Tasks?.Count ?? 0} tasks");
+
+// ───── Phase 2: Stop server (simulate restart) ─────
+
+Console.WriteLine();
+Console.WriteLine("▶ Phase 2: Stopping server (simulating crash/restart)...");
+await host1.StopAsync();
+await host1.DisposeAsync();
+Console.WriteLine(" ✓ Server stopped. Data persisted at: " + dataDir);
+
+// Show what's on disk
+Console.WriteLine();
+Console.WriteLine(" Files on disk:");
+foreach (var file in Directory.GetFiles(dataDir, "*.*", SearchOption.AllDirectories))
+{
+ var relativePath = Path.GetRelativePath(dataDir, file);
+ var size = new FileInfo(file).Length;
+ Console.WriteLine($" {relativePath} ({size} bytes)");
+}
+
+// ───── Phase 3: Start NEW server, verify data recovery ─────
+
+Console.WriteLine();
+Console.WriteLine("▶ Phase 3: Starting NEW server with same data directory...");
+var (host2, client2) = await StartServerAsync(baseUrl, agentPath, dataDir);
+
+Console.WriteLine(" Listing all tasks after restart:");
+var listAfter = await client2.ListTasksAsync(new ListTasksRequest());
+Console.WriteLine($" Found {listAfter.Tasks?.Count ?? 0} tasks (totalSize: {listAfter.TotalSize})");
+foreach (var task in listAfter.Tasks ?? [])
+{
+ Console.WriteLine($" - {task.Id}: status={task.Status.State}, context={task.ContextId}");
+}
+
+// Verify context filter still works
+Console.WriteLine();
+Console.WriteLine(" Listing tasks for context 'project-alpha' after restart:");
+var alphaTasksAfter = await client2.ListTasksAsync(new ListTasksRequest { ContextId = "project-alpha" });
+Console.WriteLine($" Found {alphaTasksAfter.Tasks?.Count ?? 0} tasks");
+
+// Get individual task details
+Console.WriteLine();
+Console.WriteLine(" Getting task details after restart:");
+try
+{
+ var details = await client2.GetTaskAsync(new GetTaskRequest { Id = task1Id });
+ Console.WriteLine($" Task {task1Id}:");
+ Console.WriteLine($" Status: {details.Status.State}");
+ Console.WriteLine($" ContextId: {details.ContextId}");
+ Console.WriteLine($" History: {details.History?.Count ?? 0} messages");
+ if (details.History is { Count: > 0 })
+ {
+ foreach (var msg in details.History)
+ {
+ var text = msg.Parts?.FirstOrDefault()?.Text ?? "(no text)";
+ Console.WriteLine($" [{msg.Role}] {text}");
+ }
+ }
+}
+catch (Exception ex)
+{
+ Console.WriteLine($" Error: {ex.Message}");
+}
+
+// ───── Phase 4: Send a follow-up to an existing task ─────
+
+Console.WriteLine();
+Console.WriteLine("▶ Phase 4: Sending follow-up message to existing task...");
+try
+{
+ var followUp = await client2.SendMessageAsync(new SendMessageRequest
+ {
+ Message = new Message
+ {
+ Role = Role.User,
+ MessageId = Guid.NewGuid().ToString(),
+ TaskId = task1Id,
+ Parts = [Part.FromText("Can you also include the revenue breakdown?")],
+ }
+ });
+ Console.WriteLine($" ✓ Follow-up sent to task {task1Id}");
+
+ var updatedTask = await client2.GetTaskAsync(new GetTaskRequest { Id = task1Id });
+ Console.WriteLine($" History now has {updatedTask.History?.Count ?? 0} messages");
+}
+catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation)
+{
+ Console.WriteLine($" ✓ Follow-up correctly rejected: {ex.Message}");
+ Console.WriteLine(" (Task is completed — spec requires rejecting messages to terminal tasks)");
+}
+
+// ───── Done ─────
+
+Console.WriteLine();
+Console.WriteLine("╔══════════════════════════════════════════════════════════════╗");
+Console.WriteLine("║ ✓ Demo complete — data survived server restart! ║");
+Console.WriteLine("╚══════════════════════════════════════════════════════════════╝");
+
+await host2.StopAsync();
+await host2.DisposeAsync();
+
+// Clean up demo data
+if (Directory.Exists(dataDir))
+ Directory.Delete(dataDir, recursive: true);
+
+return;
+
+// ─── Helper: start an A2A server with FileTaskStore ───
+
+static async Task<(WebApplication host, A2AClient client)> StartServerAsync(
+ string baseUrl, string agentPath, string dataDir)
+{
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseUrls(baseUrl);
+
+ // Register FileTaskStore BEFORE AddA2AAgent (TryAddSingleton picks up ours)
+ builder.Services.AddSingleton(sp =>
+ new FileTaskStore(dataDir));
+ builder.Services.AddA2AAgent(DemoAgent.GetAgentCard($"{baseUrl}{agentPath}"));
+
+ // Suppress noisy console output
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ var app = builder.Build();
+ app.MapA2A(agentPath);
+
+ await app.StartAsync();
+
+ var client = new A2AClient(new Uri($"{baseUrl}{agentPath}"));
+ return (app, client);
+}
+
+// ─── Simple demo agent ───
+
+file sealed class DemoAgent : IAgentHandler
+{
+ public static AgentCard GetAgentCard(string url) => new()
+ {
+ Name = "File Store Demo Agent",
+ Description = "Demonstrates FileTaskStore with data persistence across restarts.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface { Url = url, ProtocolBinding = "JSONRPC", ProtocolVersion = "1.0" }
+ ],
+ Capabilities = new AgentCapabilities { Streaming = true },
+ Skills = [new AgentSkill { Id = "demo", Name = "Demo" }],
+ DefaultInputModes = ["text"],
+ DefaultOutputModes = ["text"],
+ };
+
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct)
+ {
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+ await updater.SubmitAsync(ct);
+ await updater.StartWorkAsync(cancellationToken: ct);
+
+ // Echo back with a response
+ var userText = context.Message.Parts?.FirstOrDefault()?.Text ?? "no input";
+ var responder = new MessageResponder(eventQueue, updater.ContextId);
+ await responder.ReplyAsync($"Acknowledged: {userText}", ct);
+
+ await updater.CompleteAsync(cancellationToken: ct);
+ }
+
+ public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken ct)
+ {
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+ await updater.CancelAsync(ct);
+ }
+}
diff --git a/samples/SemanticKernelAgent/Program.cs b/samples/SemanticKernelAgent/Program.cs
index 4636be24..69e9635e 100644
--- a/samples/SemanticKernelAgent/Program.cs
+++ b/samples/SemanticKernelAgent/Program.cs
@@ -1,40 +1,55 @@
-using A2A;
-using A2A.AspNetCore;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.Extensions.DependencyInjection;
-using OpenTelemetry.Resources;
-using OpenTelemetry.Trace;
-using SemanticKernelAgent;
-
-var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddHttpClient()
- .AddLogging()
- .AddOpenTelemetry()
- .ConfigureResource(resource =>
- {
- resource.AddService("TravelAgent");
- })
- .WithTracing(tracing => tracing
- .AddSource(TaskManager.ActivitySource.Name)
- .AddSource(A2AJsonRpcProcessor.ActivitySource.Name)
- .AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddOtlpExporter(options =>
- {
- options.Endpoint = new Uri("http://localhost:4317");
- options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
- })
- );
-var app = builder.Build();
-
-var configuration = app.Configuration;
-var httpClient = app.Services.GetRequiredService().CreateClient();
-var logger = app.Logger;
-
-var agent = new SemanticKernelTravelAgent(configuration, httpClient, logger);
-var taskManager = new TaskManager();
-agent.Attach(taskManager);
-app.MapA2A(taskManager, string.Empty);
-app.MapWellKnownAgentCard(taskManager, string.Empty);
-
-await app.RunAsync();
+using A2A;
+using A2A.AspNetCore;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using SemanticKernelAgent;
+
+using Microsoft.Extensions.Logging;
+
+var builder = WebApplication.CreateBuilder(args);
+
+var agentUrl = "http://localhost:5000";
+
+// Register the SK Travel Agent — constructor needs IConfiguration, HttpClient, ILogger
+builder.Services.AddHttpClient();
+builder.Services.AddSingleton(sp =>
+{
+ var configuration = sp.GetRequiredService();
+ var httpClient = sp.GetRequiredService().CreateClient();
+ var logger = sp.GetRequiredService().CreateLogger();
+ return new SemanticKernelTravelAgent(configuration, httpClient, logger);
+});
+builder.Services.AddSingleton(SemanticKernelTravelAgent.GetAgentCard(agentUrl));
+builder.Services.AddSingleton(new A2AServerOptions());
+builder.Services.TryAddSingleton();
+builder.Services.TryAddSingleton();
+builder.Services.TryAddSingleton(sp =>
+ new A2AServer(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService()));
+
+builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService("TravelAgent"))
+ .WithTracing(tracing => tracing
+ .AddSource("A2A")
+ .AddSource("A2A.AspNetCore")
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri("http://localhost:4317");
+ options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;
+ })
+ );
+
+var app = builder.Build();
+app.MapA2A("/");
+
+await app.RunAsync();
diff --git a/samples/SemanticKernelAgent/SemanticKernelAgent.csproj b/samples/SemanticKernelAgent/SemanticKernelAgent.csproj
index d05763fb..86f1d25a 100644
--- a/samples/SemanticKernelAgent/SemanticKernelAgent.csproj
+++ b/samples/SemanticKernelAgent/SemanticKernelAgent.csproj
@@ -2,9 +2,10 @@
Exe
- net9.0
+ net10.0
enable
enable
+ $(NoWarn);CA1873;NU1904
diff --git a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs
index 8307bc21..d473be74 100644
--- a/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs
+++ b/samples/SemanticKernelAgent/SemanticKernelTravelAgent.cs
@@ -1,297 +1,281 @@
-using A2A;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Logging;
-using Microsoft.SemanticKernel;
-using Microsoft.SemanticKernel.Agents;
-using Polly;
-using System.ComponentModel;
-using System.Diagnostics;
-using System.Text.Json;
-
-namespace SemanticKernelAgent;
-
-#region Plugin
-///
-/// A simple currency plugin that leverages Frankfurter for exchange rates.
-/// The Plugin is used by the currency_exchange_agent.
-///
-public class CurrencyPlugin
-{
- private readonly ILogger _logger;
- private readonly HttpClient _httpClient;
- private readonly AsyncPolicy _retryPolicy;
-
- ///
- /// Initialize a new instance of the CurrencyPlugin
- ///
- /// Logger for the plugin
- /// HTTP client for making API requests
- public CurrencyPlugin(ILogger logger, HttpClient httpClient)
- {
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
-
- // Create a retry policy for transient HTTP errors
- _retryPolicy = Policy
- .HandleResult(r => !r.IsSuccessStatusCode && IsTransientError(r))
- .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
- }
-
- ///
- /// Retrieves exchange rate between currency_from and currency_to using Frankfurter API
- ///
- /// Currency code to convert from, e.g. USD
- /// Currency code to convert to, e.g. EUR or INR
- /// Date or 'latest'
- /// String representation of exchange rate
- [KernelFunction]
- [Description("Retrieves exchange rate between currency_from and currency_to using Frankfurter API")]
- public async Task GetExchangeRateAsync(
- [Description("Currency code to convert from, e.g. USD")] string currencyFrom,
- [Description("Currency code to convert to, e.g. EUR or INR")] string currencyTo,
- [Description("Date or 'latest'")] string date = "latest")
- {
- try
- {
- _logger.LogInformation("Getting exchange rate from {CurrencyFrom} to {CurrencyTo} for date {Date}",
- currencyFrom, currencyTo, date);
-
- // Build request URL with query parameters
- var requestUri = $"https://api.frankfurter.app/{date}?from={Uri.EscapeDataString(currencyFrom)}&to={Uri.EscapeDataString(currencyTo)}";
-
- // Use retry policy for resilience
- var response = await _retryPolicy.ExecuteAsync(() => _httpClient.GetAsync(requestUri));
- response.EnsureSuccessStatusCode();
-
- var jsonContent = await response.Content.ReadAsStringAsync();
- var data = JsonSerializer.Deserialize(jsonContent);
-
- if (!data.TryGetProperty("rates", out var rates) ||
- !rates.TryGetProperty(currencyTo, out var rate))
- {
- _logger.LogWarning("Could not retrieve rate for {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo);
- return $"Could not retrieve rate for {currencyFrom} to {currencyTo}";
- }
-
- return $"1 {currencyFrom} = {rate.GetDecimal()} {currencyTo}";
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Error getting exchange rate from {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo);
- return $"Currency API call failed: {ex.Message}";
- }
- }
-
- ///
- /// Checks if the HTTP response indicates a transient error
- ///
- /// HTTP response message
- /// True if the status code indicates a transient error
- private static bool IsTransientError(HttpResponseMessage response) =>
- (int)response.StatusCode is 408 // Request Timeout
- or 429 // Too Many Requests
- or >= 500 and < 600; // Server errors
-}
-#endregion
-
-#region Semantic Kernel Agent
-
-///
-/// Wraps Semantic Kernel-based agents to handle Travel related tasks
-///
-public class SemanticKernelTravelAgent : IDisposable
-{
- public static readonly ActivitySource ActivitySource = new("A2A.SemanticKernelTravelAgent", "1.0.0");
-
- ///
- /// Initializes a new instance of the SemanticKernelTravelAgent
- ///
- /// Application configuration
- /// HTTP client
- /// Logger for the agent
- public SemanticKernelTravelAgent(
- IConfiguration configuration,
- HttpClient httpClient,
- ILogger logger)
- {
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
- _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
-
- // Create currency plugin
- _currencyPlugin = new CurrencyPlugin(
- logger: new Logger(new LoggerFactory()),
- httpClient: _httpClient);
-
- // Initialize the agent
- _agent = InitializeAgent();
- }
-
- ///
- /// Dispose of resources
- ///
- public void Dispose()
- {
- GC.SuppressFinalize(this);
- }
-
- public void Attach(ITaskManager taskManager)
- {
- _taskManager = taskManager;
- taskManager.OnTaskCreated = ExecuteAgentTaskAsync;
- taskManager.OnTaskUpdated = ExecuteAgentTaskAsync;
- taskManager.OnAgentCardQuery = GetAgentCardAsync;
- }
-
- public async Task ExecuteAgentTaskAsync(AgentTask task, CancellationToken cancellationToken)
- {
- if (_taskManager == null)
- {
- throw new InvalidOperationException("TaskManager is not attached.");
- }
-
- await _taskManager.UpdateStatusAsync(task.Id, TaskState.Working, cancellationToken: cancellationToken);
-
- // Get message from the user
- var userMessage = task.History!.Last().Parts.First().AsTextPart().Text;
-
- // Get the response from the agent
- var artifact = new Artifact();
- await foreach (AgentResponseItem response in _agent.InvokeAsync(userMessage, cancellationToken: cancellationToken))
- {
- var content = response.Message.Content;
- artifact.Parts.Add(new TextPart() { Text = content! });
- }
-
- // Return as artifacts
- await _taskManager.ReturnArtifactAsync(task.Id, artifact, cancellationToken);
- await _taskManager.UpdateStatusAsync(task.Id, TaskState.Completed, cancellationToken: cancellationToken);
- }
-
- public static Task GetAgentCardAsync(string agentUrl, CancellationToken cancellationToken)
- {
- if (cancellationToken.IsCancellationRequested)
- {
- return Task.FromCanceled(cancellationToken);
- }
-
- var capabilities = new AgentCapabilities()
- {
- Streaming = false,
- PushNotifications = false,
- };
-
- var skillTripPlanning = new AgentSkill()
- {
- Id = "trip_planning_sk",
- Name = "Semantic Kernel Trip Planning",
- Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.",
- Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"],
- Examples =
- [
- "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.",
- "I am from Korea. What's the exchange rate and recommended itinerary for visiting Galway?",
- ],
- };
-
- return Task.FromResult(new AgentCard()
- {
- Name = "SK Travel Agent",
- Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.",
- Url = agentUrl,
- Version = "1.0.0",
- DefaultInputModes = ["text"],
- DefaultOutputModes = ["text"],
- Capabilities = capabilities,
- Skills = [skillTripPlanning],
- });
- }
-
- #region private
- private readonly ILogger _logger;
- private readonly IConfiguration _configuration;
- private readonly CurrencyPlugin _currencyPlugin;
- private readonly HttpClient _httpClient;
- private readonly ChatCompletionAgent _agent;
- private ITaskManager? _taskManager;
-
- public List SupportedContentTypes { get; } = ["text", "text/plain"];
-
- private ChatCompletionAgent InitializeAgent()
- {
- try
- {
- string provider = _configuration["Provider"] ?? "OpenAI";
- var builder = Kernel.CreateBuilder();
-
- switch (provider.ToUpperInvariant())
- {
- case "AZUREOPENAI":
- var azureConfig = _configuration.GetSection("AzureOpenAI");
- if (!azureConfig.Exists())
- {
- throw new ArgumentException("AzureOpenAI configuration section must be provided when Provider is set to 'AzureOpenAI'");
- }
- string endpoint = azureConfig["Endpoint"] ?? throw new ArgumentException("AzureOpenAI Endpoint must be provided");
- string azureApiKey = azureConfig["ApiKey"] ?? throw new ArgumentException("AzureOpenAI ApiKey must be provided");
- string deploymentName = azureConfig["DeploymentName"] ?? throw new ArgumentException("AzureOpenAI DeploymentName must be provided");
- string? apiVersion = azureConfig["ApiVersion"];
-
- _logger.LogInformation("Initializing Semantic Kernel agent with Azure OpenAI deployment {DeploymentName}", deploymentName);
- builder.AddAzureOpenAIChatCompletion(deploymentName, endpoint, azureApiKey, apiVersion: apiVersion);
- break;
-
- case "OPENAI":
- default:
- var openAiConfig = _configuration.GetSection("OpenAI");
- if (!openAiConfig.Exists())
- {
- throw new ArgumentException("OpenAI configuration section must be provided when Provider is set to 'OpenAI' or not specified");
- }
- string apiKey = openAiConfig["ApiKey"] ?? throw new ArgumentException("OpenAI ApiKey must be provided");
- string modelId = openAiConfig["Model"] ?? "gpt-4.1";
-
- _logger.LogInformation("Initializing Semantic Kernel agent with OpenAI model {ModelId}", modelId);
- builder.AddOpenAIChatCompletion(modelId, apiKey);
- break;
- }
-
- builder.Plugins.AddFromObject(_currencyPlugin);
-
- var kernel = builder.Build();
- var travelPlannerAgent = new ChatCompletionAgent()
- {
- Kernel = kernel,
- Arguments = new KernelArguments(new PromptExecutionSettings()
- { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }),
- Name = "TravelPlannerAgent",
- Instructions =
- """
- You specialize in planning and recommending activities for travelers.
- This includes suggesting sightseeing options, local events, dining recommendations,
- booking tickets for attractions, advising on travel itineraries, and ensuring activities
- align with traveler preferences and schedule.
- Your goal is to create enjoyable and personalized experiences for travelers.
- You specialize in planning and recommending activities for travelers.
- This includes suggesting sightseeing options, local events, dining recommendations,
- booking tickets for attractions, advising on travel itineraries, and ensuring activities
- align with traveler preferences and schedule.
- This includes providing current exchange rates, converting amounts between different currencies,
- explaining fees or charges related to currency exchange, and giving advice on the best practices for exchanging currency.
- Your goal is to create enjoyable and personalized experiences for travelers.
- """
- };
-
- return travelPlannerAgent;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to initialize Semantic Kernel agent");
- throw;
- }
- }
-
- #endregion
-}
-#endregion
-
+using A2A;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.Agents;
+using Polly;
+using System.ComponentModel;
+using System.Text.Json;
+
+namespace SemanticKernelAgent;
+
+#region Plugin
+///
+/// A simple currency plugin that leverages Frankfurter for exchange rates.
+/// The Plugin is used by the currency_exchange_agent.
+///
+public class CurrencyPlugin
+{
+ private readonly ILogger _logger;
+ private readonly HttpClient _httpClient;
+ private readonly AsyncPolicy _retryPolicy;
+
+ ///
+ /// Initialize a new instance of the CurrencyPlugin
+ ///
+ /// Logger for the plugin
+ /// HTTP client for making API requests
+ public CurrencyPlugin(ILogger logger, HttpClient httpClient)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+
+ // Create a retry policy for transient HTTP errors
+ _retryPolicy = Policy
+ .HandleResult(r => !r.IsSuccessStatusCode && IsTransientError(r))
+ .WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
+ }
+
+ ///
+ /// Retrieves exchange rate between currency_from and currency_to using Frankfurter API
+ ///
+ /// Currency code to convert from, e.g. USD
+ /// Currency code to convert to, e.g. EUR or INR
+ /// Date or 'latest'
+ /// String representation of exchange rate
+ [KernelFunction]
+ [Description("Retrieves exchange rate between currency_from and currency_to using Frankfurter API")]
+ public async Task GetExchangeRateAsync(
+ [Description("Currency code to convert from, e.g. USD")] string currencyFrom,
+ [Description("Currency code to convert to, e.g. EUR or INR")] string currencyTo,
+ [Description("Date or 'latest'")] string date = "latest")
+ {
+ try
+ {
+ _logger.LogInformation("Getting exchange rate from {CurrencyFrom} to {CurrencyTo} for date {Date}",
+ currencyFrom, currencyTo, date);
+
+ // Build request URL with query parameters
+ var requestUri = $"https://api.frankfurter.app/{date}?from={Uri.EscapeDataString(currencyFrom)}&to={Uri.EscapeDataString(currencyTo)}";
+
+ // Use retry policy for resilience
+ var response = await _retryPolicy.ExecuteAsync(() => _httpClient.GetAsync(requestUri));
+ response.EnsureSuccessStatusCode();
+
+ var jsonContent = await response.Content.ReadAsStringAsync();
+ var data = JsonSerializer.Deserialize(jsonContent);
+
+ if (!data.TryGetProperty("rates", out var rates) ||
+ !rates.TryGetProperty(currencyTo, out var rate))
+ {
+ _logger.LogWarning("Could not retrieve rate for {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo);
+ return $"Could not retrieve rate for {currencyFrom} to {currencyTo}";
+ }
+
+ return $"1 {currencyFrom} = {rate.GetDecimal()} {currencyTo}";
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting exchange rate from {CurrencyFrom} to {CurrencyTo}", currencyFrom, currencyTo);
+ return $"Currency API call failed: {ex.Message}";
+ }
+ }
+
+ ///
+ /// Checks if the HTTP response indicates a transient error
+ ///
+ /// HTTP response message
+ /// True if the status code indicates a transient error
+ private static bool IsTransientError(HttpResponseMessage response) =>
+ (int)response.StatusCode is 408 // Request Timeout
+ or 429 // Too Many Requests
+ or >= 500 and < 600; // Server errors
+}
+#endregion
+
+#region Semantic Kernel Agent
+
+///
+/// Wraps Semantic Kernel-based agents to handle Travel related tasks
+///
+public sealed class SemanticKernelTravelAgent : IAgentHandler, IDisposable
+{
+ ///
+ /// Initializes a new instance of the SemanticKernelTravelAgent
+ ///
+ /// Application configuration
+ /// HTTP client
+ /// Logger for the agent
+ public SemanticKernelTravelAgent(
+ IConfiguration configuration,
+ HttpClient httpClient,
+ ILogger logger)
+ {
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
+ _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
+
+ // Create currency plugin
+ _currencyPlugin = new CurrencyPlugin(
+ logger: new Logger(new LoggerFactory()),
+ httpClient: _httpClient);
+
+ // Initialize the agent
+ _agent = InitializeAgent();
+ }
+
+ ///
+ /// Dispose of resources
+ ///
+ public void Dispose()
+ {
+ GC.SuppressFinalize(this);
+ }
+
+ public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
+ {
+ var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
+ await updater.SubmitAsync(cancellationToken);
+ await updater.StartWorkAsync(cancellationToken: cancellationToken);
+
+ // Get the response from the Semantic Kernel agent
+ var artifactParts = new List();
+ await foreach (AgentResponseItem response in _agent.InvokeAsync(context.UserText ?? string.Empty, cancellationToken: cancellationToken))
+ {
+ var content = response.Message.Content;
+ artifactParts.Add(Part.FromText(content!));
+ }
+
+ await updater.AddArtifactAsync(artifactParts, cancellationToken: cancellationToken);
+ await updater.CompleteAsync(cancellationToken: cancellationToken);
+ }
+
+ public static AgentCard GetAgentCard(string agentUrl)
+ {
+ var capabilities = new AgentCapabilities()
+ {
+ Streaming = false,
+ PushNotifications = false,
+ };
+
+ var skillTripPlanning = new AgentSkill()
+ {
+ Id = "trip_planning_sk",
+ Name = "Semantic Kernel Trip Planning",
+ Description = "Handles comprehensive trip planning, including currency exchanges, itinerary creation, sightseeing, dining recommendations, and event bookings using Frankfurter API for currency conversions.",
+ Tags = ["trip", "planning", "travel", "currency", "semantic-kernel"],
+ Examples =
+ [
+ "I am from Korea. Plan a budget-friendly day trip to Dublin including currency exchange.",
+ "I am from Korea. What's the exchange rate and recommended itinerary for visiting Galway?",
+ ],
+ };
+
+ return new AgentCard()
+ {
+ Name = "SK Travel Agent",
+ Description = "Semantic Kernel-based travel agent providing comprehensive trip planning services including currency exchange and personalized activity planning.",
+ Version = "1.0.0",
+ SupportedInterfaces =
+ [
+ new AgentInterface
+ {
+ Url = agentUrl,
+ ProtocolBinding = "JSONRPC",
+ ProtocolVersion = "1.0",
+ }
+ ],
+ DefaultInputModes = ["text/plain"],
+ DefaultOutputModes = ["text/plain"],
+ Capabilities = capabilities,
+ Skills = [skillTripPlanning],
+ };
+ }
+
+ #region private
+ private readonly ILogger _logger;
+ private readonly IConfiguration _configuration;
+ private readonly CurrencyPlugin _currencyPlugin;
+ private readonly HttpClient _httpClient;
+ private readonly ChatCompletionAgent _agent;
+
+ public List SupportedContentTypes { get; } = ["text", "text/plain"];
+
+ private ChatCompletionAgent InitializeAgent()
+ {
+ try
+ {
+ string provider = _configuration["Provider"] ?? "OpenAI";
+ var builder = Kernel.CreateBuilder();
+
+ switch (provider.ToUpperInvariant())
+ {
+ case "AZUREOPENAI":
+ var azureConfig = _configuration.GetSection("AzureOpenAI");
+ if (!azureConfig.Exists())
+ {
+ throw new ArgumentException("AzureOpenAI configuration section must be provided when Provider is set to 'AzureOpenAI'");
+ }
+ string endpoint = azureConfig["Endpoint"] ?? throw new ArgumentException("AzureOpenAI Endpoint must be provided");
+ string azureApiKey = azureConfig["ApiKey"] ?? throw new ArgumentException("AzureOpenAI ApiKey must be provided");
+ string deploymentName = azureConfig["DeploymentName"] ?? throw new ArgumentException("AzureOpenAI DeploymentName must be provided");
+ string? apiVersion = azureConfig["ApiVersion"];
+
+ _logger.LogInformation("Initializing Semantic Kernel agent with Azure OpenAI deployment {DeploymentName}", deploymentName);
+ builder.AddAzureOpenAIChatCompletion(deploymentName, endpoint, azureApiKey, apiVersion: apiVersion);
+ break;
+
+ case "OPENAI":
+ default:
+ var openAiConfig = _configuration.GetSection("OpenAI");
+ if (!openAiConfig.Exists())
+ {
+ throw new ArgumentException("OpenAI configuration section must be provided when Provider is set to 'OpenAI' or not specified");
+ }
+ string apiKey = openAiConfig["ApiKey"] ?? throw new ArgumentException("OpenAI ApiKey must be provided");
+ string modelId = openAiConfig["Model"] ?? "gpt-4.1";
+
+ _logger.LogInformation("Initializing Semantic Kernel agent with OpenAI model {ModelId}", modelId);
+ builder.AddOpenAIChatCompletion(modelId, apiKey);
+ break;
+ }
+
+ builder.Plugins.AddFromObject(_currencyPlugin);
+
+ var kernel = builder.Build();
+ var travelPlannerAgent = new ChatCompletionAgent()
+ {
+ Kernel = kernel,
+ Arguments = new KernelArguments(new PromptExecutionSettings()
+ { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() }),
+ Name = "TravelPlannerAgent",
+ Instructions =
+ """
+ You specialize in planning and recommending activities for travelers.
+ This includes suggesting sightseeing options, local events, dining recommendations,
+ booking tickets for attractions, advising on travel itineraries, and ensuring activities
+ align with traveler preferences and schedule.
+ Your goal is to create enjoyable and personalized experiences for travelers.
+ You specialize in planning and recommending activities for travelers.
+ This includes suggesting sightseeing options, local events, dining recommendations,
+ booking tickets for attractions, advising on travel itineraries, and ensuring activities
+ align with traveler preferences and schedule.
+ This includes providing current exchange rates, converting amounts between different currencies,
+ explaining fees or charges related to currency exchange, and giving advice on the best practices for exchanging currency.
+ Your goal is to create enjoyable and personalized experiences for travelers.
+ """
+ };
+
+ return travelPlannerAgent;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to initialize Semantic Kernel agent");
+ throw;
+ }
+ }
+
+ #endregion
+}
+#endregion
+
diff --git a/src/A2A.AspNetCore/A2A.AspNetCore.csproj b/src/A2A.AspNetCore/A2A.AspNetCore.csproj
index af7aaa0d..35fedd25 100644
--- a/src/A2A.AspNetCore/A2A.AspNetCore.csproj
+++ b/src/A2A.AspNetCore/A2A.AspNetCore.csproj
@@ -1,7 +1,7 @@
- net9.0;net8.0
+ net10.0;net8.0
true
true
diff --git a/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs b/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs
new file mode 100644
index 00000000..a43afecb
--- /dev/null
+++ b/src/A2A.AspNetCore/A2AAspNetCoreDiagnostics.cs
@@ -0,0 +1,15 @@
+using System.Diagnostics;
+
+namespace A2A.AspNetCore;
+
+///
+/// Diagnostics for the A2A ASP.NET Core integration layer.
+///
+internal static class A2AAspNetCoreDiagnostics
+{
+ private static readonly string Version =
+ typeof(A2AAspNetCoreDiagnostics).Assembly.GetName().Version?.ToString() ?? "0.0.0";
+
+ /// Activity source for HTTP/JSON-RPC protocol processing.
+ internal static readonly ActivitySource Source = new("A2A.AspNetCore", Version);
+}
diff --git a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
index a91e58da..4b446f9e 100644
--- a/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
+++ b/src/A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs
@@ -1,120 +1,154 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
-
-namespace A2A.AspNetCore;
-
-///
-/// Extension methods for configuring A2A endpoints in ASP.NET Core applications.
-///
-public static class A2ARouteBuilderExtensions
-{
- ///
- /// Activity source for tracing A2A endpoint operations.
- ///
- public static readonly ActivitySource ActivitySource = new("A2A.Endpoint", "1.0.0");
-
- ///
- /// Enables JSON-RPC A2A endpoints for the specified path.
- ///
- /// The endpoint route builder to configure.
- /// The task manager for handling A2A operations.
- /// The base path for the A2A endpoints.
- /// An endpoint convention builder for further configuration.
- public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string path)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- ArgumentNullException.ThrowIfNull(taskManager);
- ArgumentException.ThrowIfNullOrEmpty(path);
-
- var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
- var logger = loggerFactory.CreateLogger();
-
- var routeGroup = endpoints.MapGroup("");
-
- routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) => A2AJsonRpcProcessor.ProcessRequestAsync(taskManager, request, cancellationToken));
-
- return routeGroup;
- }
-
- ///
- /// Enables the well-known agent card endpoint for agent discovery.
- ///
- /// The endpoint route builder to configure.
- /// The task manager for handling A2A operations.
- /// The base path where the A2A agent is hosted.
- /// An endpoint convention builder for further configuration.
- public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string agentPath)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- ArgumentNullException.ThrowIfNull(taskManager);
- ArgumentException.ThrowIfNullOrEmpty(agentPath);
-
- var routeGroup = endpoints.MapGroup("");
-
- routeGroup.MapGet(".well-known/agent-card.json", async (HttpRequest request, CancellationToken cancellationToken) =>
- {
- var agentUrl = $"{request.Scheme}://{request.Host}{agentPath}";
- var agentCard = await taskManager.OnAgentCardQuery(agentUrl, cancellationToken);
- return Results.Ok(agentCard);
- });
-
- return routeGroup;
- }
-
- ///
- /// Enables experimental HTTP A2A endpoints for the specified path.
- ///
- /// The endpoint route builder to configure.
- /// The task manager for handling A2A operations.
- /// The base path for the HTTP A2A endpoints.
- /// An endpoint convention builder for further configuration.
- public static IEndpointConventionBuilder MapHttpA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, [StringSyntax("Route")] string path)
- {
- ArgumentNullException.ThrowIfNull(endpoints);
- ArgumentNullException.ThrowIfNull(taskManager);
- ArgumentException.ThrowIfNullOrEmpty(path);
-
- var loggerFactory = endpoints.ServiceProvider.GetRequiredService();
- var logger = loggerFactory.CreateLogger();
-
- var routeGroup = endpoints.MapGroup(path);
-
- // /v1/card endpoint - Agent discovery
- routeGroup.MapGet("/v1/card", async (HttpRequest request, CancellationToken cancellationToken) =>
- await A2AHttpProcessor.GetAgentCardAsync(taskManager, logger, $"{request.Scheme}://{request.Host}{path}", cancellationToken).ConfigureAwait(false));
-
- // /v1/tasks/{id} endpoint
- routeGroup.MapGet("/v1/tasks/{id}", (string id, [FromQuery] int? historyLength, [FromQuery] string? metadata, CancellationToken cancellationToken) =>
- A2AHttpProcessor.GetTaskAsync(taskManager, logger, id, historyLength, metadata, cancellationToken));
-
- // /v1/tasks/{id}:cancel endpoint
- routeGroup.MapPost("/v1/tasks/{id}:cancel", (string id, CancellationToken cancellationToken) => A2AHttpProcessor.CancelTaskAsync(taskManager, logger, id, cancellationToken));
-
- // /v1/tasks/{id}:subscribe endpoint
- routeGroup.MapGet("/v1/tasks/{id}:subscribe", (string id, CancellationToken cancellationToken) => A2AHttpProcessor.SubscribeToTask(taskManager, logger, id, cancellationToken));
-
- // /v1/tasks/{id}/pushNotificationConfigs endpoint - POST
- routeGroup.MapPost("/v1/tasks/{id}/pushNotificationConfigs", (string id, [FromBody] PushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken) =>
- A2AHttpProcessor.SetPushNotificationAsync(taskManager, logger, id, pushNotificationConfig, cancellationToken));
-
- // /v1/tasks/{id}/pushNotificationConfigs endpoint - GET
- routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs/{notificationConfigId?}", (string id, string? notificationConfigId, CancellationToken cancellationToken) =>
- A2AHttpProcessor.GetPushNotificationAsync(taskManager, logger, id, notificationConfigId, cancellationToken));
-
- // /v1/message:send endpoint
- routeGroup.MapPost("/v1/message:send", ([FromBody] MessageSendParams sendParams, CancellationToken cancellationToken) =>
- A2AHttpProcessor.SendMessageAsync(taskManager, logger, sendParams, cancellationToken));
-
- // /v1/message:stream endpoint
- routeGroup.MapPost("/v1/message:stream", ([FromBody] MessageSendParams sendParams, CancellationToken cancellationToken) =>
- A2AHttpProcessor.SendMessageStream(taskManager, logger, sendParams, cancellationToken));
-
- return routeGroup;
- }
-}
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using System.Diagnostics.CodeAnalysis;
+
+namespace A2A.AspNetCore;
+
+///
+/// Extension methods for configuring A2A endpoints in ASP.NET Core applications.
+///
+public static class A2ARouteBuilderExtensions
+{
+ ///
+ /// Maps A2A JSON-RPC endpoint and well-known agent card using DI-registered services.
+ /// Requires prior call to .
+ ///
+ /// The endpoint route builder.
+ /// The route path for the A2A endpoint.
+ /// An endpoint convention builder for further configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string path)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentException.ThrowIfNullOrEmpty(path);
+
+ var handler = endpoints.ServiceProvider.GetRequiredService();
+ var agentCard = endpoints.ServiceProvider.GetRequiredService();
+
+ var routeGroup = endpoints.MapGroup("");
+ routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken)
+ => A2AJsonRpcProcessor.ProcessRequestAsync(handler, request, cancellationToken));
+
+ var wellKnown = endpoints.MapGroup(path);
+ wellKnown.MapGet(".well-known/agent-card.json", () => Results.Ok(agentCard));
+
+ return routeGroup;
+ }
+
+ /// Enables JSON-RPC A2A endpoints for the specified path.
+ /// The endpoint route builder.
+ /// The A2A request handler.
+ /// The route path for the A2A endpoint.
+ /// An endpoint convention builder for further configuration.
+ public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, [StringSyntax("Route")] string path)
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(requestHandler);
+ ArgumentException.ThrowIfNullOrEmpty(path);
+
+ var routeGroup = endpoints.MapGroup("");
+
+ routeGroup.MapPost(path, (HttpRequest request, CancellationToken cancellationToken) => A2AJsonRpcProcessor.ProcessRequestAsync(requestHandler, request, cancellationToken));
+
+ return routeGroup;
+ }
+
+ /// Enables the well-known agent card endpoint for agent discovery.
+ /// The endpoint route builder.
+ /// The agent card to serve.
+ /// An optional route prefix. When provided, the agent card is served at {path}/.well-known/agent-card.json.
+ /// An endpoint convention builder for further configuration.
+ public static IEndpointConventionBuilder MapWellKnownAgentCard(this IEndpointRouteBuilder endpoints, AgentCard agentCard, [StringSyntax("Route")] string path = "")
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(agentCard);
+
+ var routeGroup = endpoints.MapGroup(path);
+
+ routeGroup.MapGet(".well-known/agent-card.json", () => Results.Ok(agentCard));
+
+ return routeGroup;
+ }
+
+ ///
+ /// Maps HTTP+JSON REST API endpoints for A2A v1.
+ ///
+ ///
+ /// All REST endpoints are prefixed with /v1/ (e.g., /v1/tasks/{id}).
+ /// The A2A specification defines routes without this prefix (e.g., /tasks/{id}).
+ /// Configure the parameter to control the base path.
+ /// Limitation: Multi-tenant route variants
+ /// (/{tenant}/tasks/{id}) defined in the A2A specification are not currently
+ /// supported. The Tenant field on request types will always be null
+ /// for REST API calls. Use the JSON-RPC binding with explicit tenant parameters
+ /// if multi-tenant routing is required.
+ ///
+ /// The endpoint route builder.
+ /// The A2A request handler.
+ /// The agent card to serve at the /v1/card endpoint.
+ /// The route prefix for all REST endpoints.
+ /// An endpoint convention builder for further configuration.
+ public static IEndpointConventionBuilder MapHttpA2A(
+ this IEndpointRouteBuilder endpoints, IA2ARequestHandler requestHandler, AgentCard agentCard, [StringSyntax("Route")] string path = "")
+ {
+ ArgumentNullException.ThrowIfNull(endpoints);
+ ArgumentNullException.ThrowIfNull(requestHandler);
+ ArgumentNullException.ThrowIfNull(agentCard);
+
+ var routeGroup = endpoints.MapGroup(path);
+ var logger = endpoints.ServiceProvider.GetRequiredService().CreateLogger("A2A.REST");
+
+ // Agent card
+ routeGroup.MapGet("/v1/card", (CancellationToken ct)
+ => A2AHttpProcessor.GetAgentCardRestAsync(requestHandler, logger, agentCard, ct));
+
+ // Task operations
+ routeGroup.MapGet("/v1/tasks/{id}", (string id, [FromQuery] int? historyLength, CancellationToken ct)
+ => A2AHttpProcessor.GetTaskRestAsync(requestHandler, logger, id, historyLength, ct));
+
+ routeGroup.MapPost("/v1/tasks/{id}:cancel", (string id, CancellationToken ct)
+ => A2AHttpProcessor.CancelTaskRestAsync(requestHandler, logger, id, ct));
+
+ routeGroup.MapGet("/v1/tasks/{id}:subscribe", (string id, CancellationToken ct)
+ => A2AHttpProcessor.SubscribeToTaskRest(requestHandler, logger, id, ct));
+
+ routeGroup.MapGet("/v1/tasks", ([FromQuery] string? contextId, [FromQuery] string? status,
+ [FromQuery] int? pageSize, [FromQuery] string? pageToken, [FromQuery] int? historyLength,
+ CancellationToken ct)
+ => A2AHttpProcessor.ListTasksRestAsync(requestHandler, logger, contextId, status, pageSize, pageToken,
+ historyLength, ct));
+
+ // Message operations
+ routeGroup.MapPost("/v1/message:send", ([FromBody] SendMessageRequest request, CancellationToken ct)
+ => A2AHttpProcessor.SendMessageRestAsync(requestHandler, logger, request, ct));
+
+ routeGroup.MapPost("/v1/message:stream", ([FromBody] SendMessageRequest request, CancellationToken ct)
+ => A2AHttpProcessor.SendMessageStreamRest(requestHandler, logger, request, ct));
+
+ // Push notification config operations
+ routeGroup.MapPost("/v1/tasks/{id}/pushNotificationConfigs",
+ (string id, [FromBody] PushNotificationConfig config, CancellationToken ct)
+ => A2AHttpProcessor.CreatePushNotificationConfigRestAsync(requestHandler, logger, id, config, ct));
+
+ routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs",
+ (string id, [FromQuery] int? pageSize, [FromQuery] string? pageToken, CancellationToken ct)
+ => A2AHttpProcessor.ListPushNotificationConfigRestAsync(requestHandler, logger, id, pageSize, pageToken, ct));
+
+ routeGroup.MapGet("/v1/tasks/{id}/pushNotificationConfigs/{configId}",
+ (string id, string configId, CancellationToken ct)
+ => A2AHttpProcessor.GetPushNotificationConfigRestAsync(requestHandler, logger, id, configId, ct));
+
+ routeGroup.MapDelete("/v1/tasks/{id}/pushNotificationConfigs/{configId}",
+ (string id, string configId, CancellationToken ct)
+ => A2AHttpProcessor.DeletePushNotificationConfigRestAsync(requestHandler, logger, id, configId, ct));
+
+ // Extended agent card
+ routeGroup.MapGet("/v1/extendedAgentCard", (CancellationToken ct)
+ => A2AHttpProcessor.GetExtendedAgentCardRestAsync(requestHandler, logger, ct));
+
+ return routeGroup;
+ }
+}
diff --git a/src/A2A.AspNetCore/A2AHttpProcessor.cs b/src/A2A.AspNetCore/A2AHttpProcessor.cs
index eb3a95d1..0eeed5af 100644
--- a/src/A2A.AspNetCore/A2AHttpProcessor.cs
+++ b/src/A2A.AspNetCore/A2AHttpProcessor.cs
@@ -1,407 +1,344 @@
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Features;
-using Microsoft.Extensions.Logging;
-
-using System.Diagnostics;
-using System.Text;
-using System.Text.Json;
-
-namespace A2A.AspNetCore;
-
-///
-/// Static processor class for handling A2A HTTP requests in ASP.NET Core applications.
-///
-///
-/// Provides methods for processing agent card queries, task operations, message sending,
-/// and push notification configuration through HTTP endpoints.
-///
-internal static class A2AHttpProcessor
-{
- ///
- /// OpenTelemetry ActivitySource for tracing HTTP processor operations.
- ///
- public static readonly ActivitySource ActivitySource = new("A2A.HttpProcessor", "1.0.0");
-
- ///
- /// Processes a request to retrieve the agent card containing agent capabilities and metadata.
- ///
- ///
- /// Invokes the task manager's agent card query handler to get current agent information.
- ///
- /// The task manager instance containing the agent card query handler.
- /// Logger instance for recording operation details and errors.
- /// The URL of the agent to retrieve the card for.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the agent card JSON or an error response.
- internal static Task GetAgentCardAsync(ITaskManager taskManager, ILogger logger, string agentUrl, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "GetAgentCard", async ct =>
- {
- var agentCard = await taskManager.OnAgentCardQuery(agentUrl, ct);
-
- return Results.Ok(agentCard);
- }, cancellationToken: cancellationToken);
-
- ///
- /// Processes a request to retrieve a specific task by its ID.
- ///
- ///
- /// Returns the task's current state, history, and metadata with optional history length limiting.
- ///
- /// The task manager instance for accessing task storage.
- /// Logger instance for recording operation details and errors.
- /// The unique identifier of the task to retrieve.
- /// Optional limit on the number of history items to return.
- /// Optional JSON metadata filter for the task query.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the task JSON or a not found/error response.
- internal static Task GetTaskAsync(ITaskManager taskManager, ILogger logger, string id, int? historyLength, string? metadata, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "GetTask", async ct =>
- {
- var agentTask = await taskManager.GetTaskAsync(new TaskQueryParams()
- {
- Id = id,
- HistoryLength = historyLength,
- Metadata = string.IsNullOrWhiteSpace(metadata) ? null : (Dictionary?)JsonSerializer.Deserialize(metadata, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(Dictionary)))
- }, ct).ConfigureAwait(false);
-
- return agentTask is not null ? new A2AResponseResult(agentTask) : Results.NotFound();
- }, id, cancellationToken: cancellationToken);
-
- ///
- /// Processes a request to cancel a specific task by setting its status to Canceled.
- ///
- ///
- /// Invokes the task manager's cancellation logic and returns the updated task state.
- ///
- /// The task manager instance for handling task cancellation.
- /// Logger instance for recording operation details and errors.
- /// The unique identifier of the task to cancel.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the canceled task JSON or a not found/error response.
- internal static Task CancelTaskAsync(ITaskManager taskManager, ILogger logger, string id, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "CancelTask", async ct =>
- {
- var agentTask = await taskManager.CancelTaskAsync(new TaskIdParams { Id = id }, ct).ConfigureAwait(false);
- if (agentTask == null)
- {
- return Results.NotFound();
- }
-
- return new A2AResponseResult(agentTask);
- }, id, cancellationToken: cancellationToken);
-
- ///
- /// Processes a request to send a message to a task and return a single response.
- ///
- ///
- /// Creates a new task if no task ID is provided, or updates an existing task's history.
- /// Configures message sending parameters including history length and metadata.
- ///
- /// The task manager instance for handling message processing.
- /// Logger instance for recording operation details and errors.
- /// The message parameters containing the message content and configuration.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the agent's response (Task or Message) or an error response.
- internal static Task SendMessageAsync(ITaskManager taskManager, ILogger logger, MessageSendParams sendParams, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "SendMessage", async ct =>
- {
- var a2aResponse = await taskManager.SendMessageAsync(sendParams, ct).ConfigureAwait(false);
- if (a2aResponse == null)
- {
- return Results.NotFound();
- }
-
- return new A2AResponseResult(a2aResponse);
- }, cancellationToken: cancellationToken);
-
- ///
- /// Processes a request to send a message to a task and return a stream of events.
- ///
- ///
- /// Creates or updates a task and establishes a Server-Sent Events stream that yields
- /// Task, Message, TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects as they occur.
- ///
- /// The task manager instance for handling streaming message processing.
- /// Logger instance for recording operation details and errors.
- /// The message parameters containing the message content and configuration.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result that streams events as Server-Sent Events or an error response.
- internal static IResult SendMessageStream(ITaskManager taskManager, ILogger logger, MessageSendParams sendParams, CancellationToken cancellationToken)
- => WithExceptionHandling(logger, nameof(SendMessageStream), () =>
- {
- var taskEvents = taskManager.SendMessageStreamingAsync(sendParams, cancellationToken);
-
- return new A2AEventStreamResult(taskEvents);
- }, sendParams.Message.TaskId);
-
- ///
- /// Processes a request to resubscribe to an existing task's event stream.
- ///
- ///
- /// Returns the active event enumerator for the specified task, allowing clients
- /// to reconnect to ongoing task updates via Server-Sent Events.
- ///
- /// The task manager instance containing active task event streams.
- /// Logger instance for recording operation details and errors.
- /// The unique identifier of the task to resubscribe to.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result that streams existing task events or an error response.
- internal static IResult SubscribeToTask(ITaskManager taskManager, ILogger logger, string id, CancellationToken cancellationToken)
- => WithExceptionHandling(logger, nameof(SubscribeToTask), () =>
- {
- var taskEvents = taskManager.SubscribeToTaskAsync(new TaskIdParams { Id = id }, cancellationToken);
-
- return new A2AEventStreamResult(taskEvents);
- }, id);
-
- ///
- /// Processes a request to set or update push notification configuration for a specific task.
- ///
- ///
- /// Configures callback URLs and authentication settings for receiving task update notifications via HTTP.
- ///
- /// The task manager instance for handling push notification configuration.
- /// Logger instance for recording operation details and errors.
- /// The unique identifier of the task to configure push notifications for.
- /// The push notification configuration containing callback URL and authentication details.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the configured settings or an error response.
- internal static Task SetPushNotificationAsync(ITaskManager taskManager, ILogger logger, string id, PushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "SetPushNotification", async ct =>
- {
- var taskIdParams = new TaskIdParams { Id = id };
- var result = await taskManager.SetPushNotificationAsync(new TaskPushNotificationConfig
- {
- TaskId = id,
- PushNotificationConfig = pushNotificationConfig
- }, ct).ConfigureAwait(false);
-
- if (result == null)
- {
- return Results.NotFound();
- }
-
- return Results.Ok(result);
- }, id, cancellationToken: cancellationToken);
-
- ///
- /// Processes a request to retrieve the push notification configuration for a specific task.
- ///
- ///
- /// Returns the callback URL and authentication settings configured for receiving task update notifications.
- ///
- /// The task manager instance for accessing push notification configurations.
- /// Logger instance for recording operation details and errors.
- /// The unique identifier of the task to get push notification configuration for.
- /// The unique identifier of the push notification configuration to retrieve.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result containing the push notification configuration or a not found/error response.
- internal static Task GetPushNotificationAsync(ITaskManager taskManager, ILogger logger, string taskId, string? notificationConfigId, CancellationToken cancellationToken)
- => WithExceptionHandlingAsync(logger, "GetPushNotification", async ct =>
- {
- var taskIdParams = new GetTaskPushNotificationConfigParams { Id = taskId, PushNotificationConfigId = notificationConfigId };
- var result = await taskManager.GetPushNotificationAsync(taskIdParams, ct).ConfigureAwait(false);
-
- if (result == null)
- {
- return Results.NotFound();
- }
-
- return Results.Ok(result);
- }, taskId, cancellationToken: cancellationToken);
-
- ///
- /// Provides centralized exception handling for HTTP A2A endpoints.
- ///
- /// Logger instance for recording operation details and errors.
- /// Name of the operation for logging purposes.
- /// The operation to execute with exception handling.
- /// Optional task ID to include in the activity for tracing.
- /// Cancellation token for the async operation
- /// A task that represents the HTTP result with proper error handling.
- private static async Task WithExceptionHandlingAsync(ILogger logger, string activityName,
- Func> operation, string? taskId = null, CancellationToken cancellationToken = default)
- {
- using var activity = ActivitySource.StartActivity(activityName, ActivityKind.Server);
- if (taskId is not null)
- {
- activity?.AddTag("task.id", taskId);
- }
-
- try
- {
- return await operation(cancellationToken);
- }
- catch (A2AException ex)
- {
- logger.A2AErrorInActivityName(ex, activityName);
- return MapA2AExceptionToHttpResult(ex);
- }
- catch (Exception ex)
- {
- logger.UnexpectedErrorInActivityName(ex, activityName);
- return Results.Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError);
- }
- }
-
- ///
- /// Provides centralized exception handling for HTTP A2A endpoints.
- ///
- /// Logger instance for recording operation details and errors.
- /// Name of the operation for logging purposes.
- /// The operation to execute with exception handling.
- /// Optional task ID to include in the activity for tracing.
- /// A task that represents the HTTP result with proper error handling.
- private static IResult WithExceptionHandling(ILogger logger, string activityName,
- Func operation, string? taskId = null)
- {
- using var activity = ActivitySource.StartActivity(activityName, ActivityKind.Server);
- if (taskId is not null)
- {
- activity?.AddTag("task.id", taskId);
- }
-
- try
- {
- return operation();
- }
- catch (A2AException ex)
- {
- logger.A2AErrorInActivityName(ex, activityName);
- return MapA2AExceptionToHttpResult(ex);
- }
- catch (Exception ex)
- {
- logger.UnexpectedErrorInActivityName(ex, activityName);
- return Results.Problem(detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError);
- }
- }
-
- ///
- /// Maps an A2AException to an appropriate HTTP result based on the error code.
- ///
- /// The A2AException to map to an HTTP result.
- /// An HTTP result with the appropriate status code and error message.
- private static IResult MapA2AExceptionToHttpResult(A2AException exception)
- {
- return exception.ErrorCode switch
- {
- A2AErrorCode.TaskNotFound or
- A2AErrorCode.MethodNotFound => Results.NotFound(exception.Message),
-
- A2AErrorCode.TaskNotCancelable or
- A2AErrorCode.UnsupportedOperation or
- A2AErrorCode.InvalidRequest or
- A2AErrorCode.InvalidParams or
- A2AErrorCode.ParseError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest),
-
- // Return HTTP 400 for now. Later we may want to return 501 Not Implemented in case
- // push notifications are advertised by agent card(AgentCard.capabilities.pushNotifications: true)
- // but there's no server-side support for them.
- A2AErrorCode.PushNotificationNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest),
-
- A2AErrorCode.ContentTypeNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity),
-
- A2AErrorCode.InternalError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError),
-
- // Default case for unhandled error codes - this should never happen with current A2AErrorCode enum values
- // but provides a safety net for future enum additions or unexpected values
- _ => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError)
- };
- }
-}
-
-///
-/// Result type for returning A2A responses as JSON in HTTP responses.
-///
-///
-/// Implements IResult to provide custom serialization of A2AResponse objects
-/// using the configured JSON serialization options.
-///
-public class A2AResponseResult : IResult
-{
- private readonly A2AResponse a2aResponse;
-
- ///
- /// Initializes a new instance of the A2AResponseResult class.
- ///
- /// The A2A response object to serialize and return in the HTTP response.
- public A2AResponseResult(A2AResponse a2aResponse)
- {
- this.a2aResponse = a2aResponse;
- }
-
- ///
- /// Executes the result by serializing the A2A response as JSON to the HTTP response body.
- ///
- ///
- /// Sets the appropriate content type and uses the default A2A JSON serialization options.
- ///
- /// The HTTP context to write the response to.
- /// A task representing the asynchronous serialization operation.
- public async Task ExecuteAsync(HttpContext httpContext)
- {
- httpContext.Response.ContentType = "application/json";
-
- await JsonSerializer.SerializeAsync(httpContext.Response.Body, a2aResponse, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AResponse))).ConfigureAwait(false);
- }
-}
-
-///
-/// Result type for streaming A2A events as Server-Sent Events (SSE) in HTTP responses.
-///
-///
-/// Implements IResult to provide real-time streaming of task events including Task objects,
-/// TaskStatusUpdateEvent, and TaskArtifactUpdateEvent objects.
-///
-internal sealed class A2AEventStreamResult : IResult
-{
- private readonly IAsyncEnumerable taskEvents;
-
- internal A2AEventStreamResult(IAsyncEnumerable taskEvents)
- {
- ArgumentNullException.ThrowIfNull(taskEvents);
-
- this.taskEvents = taskEvents;
- }
-
- ///
- /// Executes the result by streaming A2A events as Server-Sent Events to the HTTP response.
- ///
- ///
- /// Sets the appropriate SSE content type and formats each event according to the SSE specification.
- /// Each event is serialized as JSON and sent with the "data:" prefix followed by double newlines.
- ///
- /// The HTTP context to stream the events to.
- /// A task representing the asynchronous streaming operation.
- public async Task ExecuteAsync(HttpContext httpContext)
- {
- ArgumentNullException.ThrowIfNull(httpContext);
-
- httpContext.Response.ContentType = "text/event-stream";
-
- // .NET 10 Preview 3 has introduced ServerSentEventsResult (and TypedResults.ServerSentEventsResult),
- // but we need to support .NET 8 and 9, so we implement our own SSE result
- // and mimic what the forementioned types do.
- httpContext.Response.Headers.CacheControl = "no-cache,no-store";
- httpContext.Response.Headers.Pragma = "no-cache";
- httpContext.Response.Headers.ContentEncoding = "identity";
-
- var bufferingFeature = httpContext.Features.GetRequiredFeature();
- bufferingFeature.DisableBuffering();
-
- try
- {
- await foreach (var taskEvent in taskEvents)
- {
- var json = JsonSerializer.Serialize(taskEvent, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(A2AEvent)));
- await httpContext.Response.BodyWriter.WriteAsync(Encoding.UTF8.GetBytes($"data: {json}\n\n"), httpContext.RequestAborted).ConfigureAwait(false);
- await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted).ConfigureAwait(false);
- }
- }
- catch (OperationCanceledException)
- {
- // Closed connection
- }
- }
-}
\ No newline at end of file
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.Logging;
+
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+
+namespace A2A.AspNetCore;
+
+///
+/// Static processor class for handling A2A HTTP requests in ASP.NET Core applications.
+///
+internal static class A2AHttpProcessor
+{
+ internal static Task GetTaskAsync(IA2ARequestHandler requestHandler, ILogger logger, string id, int? historyLength, string? metadata, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "GetTask", async ct =>
+ {
+ var agentTask = await requestHandler.GetTaskAsync(new GetTaskRequest
+ {
+ Id = id,
+ HistoryLength = historyLength,
+ }, ct).ConfigureAwait(false);
+
+ return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), agentTask));
+ }, id, cancellationToken: cancellationToken);
+
+ internal static Task CancelTaskAsync(IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "CancelTask", async ct =>
+ {
+ var cancelledTask = await requestHandler.CancelTaskAsync(new CancelTaskRequest { Id = id }, ct).ConfigureAwait(false);
+ return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), cancelledTask));
+ }, id, cancellationToken: cancellationToken);
+
+ internal static Task SendMessageAsync(IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest sendRequest, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "SendMessage", async ct =>
+ {
+ var result = await requestHandler.SendMessageAsync(sendRequest, ct).ConfigureAwait(false);
+ return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcResponse(new JsonRpcId("http"), result));
+ }, cancellationToken: cancellationToken);
+
+ internal static IResult SendMessageStream(IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest sendRequest, CancellationToken cancellationToken)
+ => WithExceptionHandling(logger, nameof(SendMessageStream), () =>
+ {
+ var events = requestHandler.SendStreamingMessageAsync(sendRequest, cancellationToken);
+ return new JsonRpcStreamedResult(events, new JsonRpcId("http"));
+ });
+
+ internal static IResult SubscribeToTask(IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken)
+ => WithExceptionHandling(logger, nameof(SubscribeToTask), () =>
+ {
+ var events = requestHandler.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = id }, cancellationToken);
+ return new JsonRpcStreamedResult(events, new JsonRpcId("http"));
+ }, id);
+
+ private static async Task WithExceptionHandlingAsync(ILogger logger, string activityName,
+ Func> operation, string? taskId = null, CancellationToken cancellationToken = default)
+ {
+ using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity(activityName, ActivityKind.Server);
+ if (taskId is not null)
+ {
+ activity?.SetTag("task.id", taskId);
+ }
+
+ try
+ {
+ return await operation(cancellationToken);
+ }
+ catch (A2AException ex)
+ {
+ logger.A2AErrorInActivityName(ex, activityName);
+ return MapA2AExceptionToHttpResult(ex);
+ }
+ catch (Exception ex)
+ {
+ logger.UnexpectedErrorInActivityName(ex, activityName);
+ return Results.Problem(detail: "An internal error occurred.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ private static IResult WithExceptionHandling(ILogger logger, string activityName,
+ Func operation, string? taskId = null)
+ {
+ using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity(activityName, ActivityKind.Server);
+ if (taskId is not null)
+ {
+ activity?.SetTag("task.id", taskId);
+ }
+
+ try
+ {
+ return operation();
+ }
+ catch (A2AException ex)
+ {
+ logger.A2AErrorInActivityName(ex, activityName);
+ return MapA2AExceptionToHttpResult(ex);
+ }
+ catch (Exception ex)
+ {
+ logger.UnexpectedErrorInActivityName(ex, activityName);
+ return Results.Problem(detail: "An internal error occurred.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ private static IResult MapA2AExceptionToHttpResult(A2AException exception)
+ {
+ return exception.ErrorCode switch
+ {
+ A2AErrorCode.TaskNotFound or
+ A2AErrorCode.MethodNotFound => Results.NotFound(exception.Message),
+
+ A2AErrorCode.TaskNotCancelable or
+ A2AErrorCode.UnsupportedOperation or
+ A2AErrorCode.InvalidRequest or
+ A2AErrorCode.InvalidParams or
+ A2AErrorCode.ParseError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest),
+
+ A2AErrorCode.PushNotificationNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest),
+
+ A2AErrorCode.ContentTypeNotSupported => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity),
+
+ A2AErrorCode.InternalError => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError),
+
+ _ => Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status500InternalServerError)
+ };
+ }
+
+ // ======= REST API handler methods =======
+
+ // REST handler: Get agent card
+ internal static Task GetAgentCardRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, AgentCard agentCard, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.GetAgentCard", ct =>
+ Task.FromResult(new A2AResponseResult(agentCard)), cancellationToken: cancellationToken);
+
+ // REST handler: Get task by ID
+ internal static Task GetTaskRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string id, int? historyLength, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.GetTask", async ct =>
+ {
+ var result = await requestHandler.GetTaskAsync(
+ new GetTaskRequest { Id = id, HistoryLength = historyLength }, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, id, cancellationToken);
+
+ // REST handler: Cancel task
+ internal static Task CancelTaskRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.CancelTask", async ct =>
+ {
+ var result = await requestHandler.CancelTaskAsync(
+ new CancelTaskRequest { Id = id }, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, id, cancellationToken);
+
+ // REST handler: Send message
+ internal static Task SendMessageRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest request, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.SendMessage", async ct =>
+ {
+ var result = await requestHandler.SendMessageAsync(request, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, cancellationToken: cancellationToken);
+
+ // REST handler: Send streaming message
+ internal static IResult SendMessageStreamRest(
+ IA2ARequestHandler requestHandler, ILogger logger, SendMessageRequest request, CancellationToken cancellationToken)
+ => WithExceptionHandling(logger, "REST.SendMessageStream", () =>
+ {
+ var events = requestHandler.SendStreamingMessageAsync(request, cancellationToken);
+ return new A2AEventStreamResult(events);
+ });
+
+ // REST handler: Subscribe to task
+ internal static IResult SubscribeToTaskRest(
+ IA2ARequestHandler requestHandler, ILogger logger, string id, CancellationToken cancellationToken)
+ => WithExceptionHandling(logger, "REST.SubscribeToTask", () =>
+ {
+ var events = requestHandler.SubscribeToTaskAsync(
+ new SubscribeToTaskRequest { Id = id }, cancellationToken);
+ return new A2AEventStreamResult(events);
+ }, id);
+
+ // REST handler: List tasks
+ internal static Task ListTasksRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string? contextId, string? status, int? pageSize,
+ string? pageToken, int? historyLength, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.ListTasks", async ct =>
+ {
+ var request = new ListTasksRequest
+ {
+ ContextId = contextId,
+ PageSize = pageSize,
+ PageToken = pageToken,
+ HistoryLength = historyLength,
+ };
+ if (!string.IsNullOrEmpty(status))
+ {
+ if (!Enum.TryParse(status, ignoreCase: true, out var taskState))
+ {
+ return Results.Problem(
+ detail: $"Invalid status filter: '{status}'. Valid values: {string.Join(", ", Enum.GetNames())}",
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ request.Status = taskState;
+ }
+
+ var result = await requestHandler.ListTasksAsync(request, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, cancellationToken: cancellationToken);
+
+ // REST handler: Get extended agent card
+ internal static Task GetExtendedAgentCardRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.GetExtendedAgentCard", async ct =>
+ {
+ var result = await requestHandler.GetExtendedAgentCardAsync(
+ new GetExtendedAgentCardRequest(), ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, cancellationToken: cancellationToken);
+
+ // REST handler: Create push notification config
+ internal static Task CreatePushNotificationConfigRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string taskId, PushNotificationConfig config, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.CreatePushNotificationConfig", async ct =>
+ {
+ var request = new CreateTaskPushNotificationConfigRequest
+ {
+ TaskId = taskId,
+ Config = config,
+ ConfigId = config.Id ?? string.Empty,
+ };
+ var result = await requestHandler.CreateTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, taskId, cancellationToken);
+
+ // REST handler: List push notification configs for a task
+ internal static Task ListPushNotificationConfigRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string taskId, int? pageSize, string? pageToken,
+ CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.ListPushNotificationConfig", async ct =>
+ {
+ var request = new ListTaskPushNotificationConfigRequest
+ {
+ TaskId = taskId,
+ PageSize = pageSize,
+ PageToken = pageToken,
+ };
+ var result = await requestHandler.ListTaskPushNotificationConfigAsync(request, ct)
+ .ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, taskId, cancellationToken);
+
+ // REST handler: Get push notification config
+ internal static Task GetPushNotificationConfigRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string taskId, string configId, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.GetPushNotificationConfig", async ct =>
+ {
+ var request = new GetTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId };
+ var result = await requestHandler.GetTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false);
+ return new A2AResponseResult(result);
+ }, taskId, cancellationToken);
+
+ // REST handler: Delete push notification config
+ internal static Task DeletePushNotificationConfigRestAsync(
+ IA2ARequestHandler requestHandler, ILogger logger, string taskId, string configId, CancellationToken cancellationToken)
+ => WithExceptionHandlingAsync(logger, "REST.DeletePushNotificationConfig", async ct =>
+ {
+ var request = new DeleteTaskPushNotificationConfigRequest { TaskId = taskId, Id = configId };
+ await requestHandler.DeleteTaskPushNotificationConfigAsync(request, ct).ConfigureAwait(false);
+ return Results.NoContent();
+ }, taskId, cancellationToken);
+}
+
+/// IResult for REST API JSON responses.
+internal sealed class A2AResponseResult : IResult
+{
+ private readonly object _response;
+ private readonly Type _responseType;
+
+ internal A2AResponseResult(SendMessageResponse response) { _response = response; _responseType = typeof(SendMessageResponse); }
+ internal A2AResponseResult(AgentTask task) { _response = task; _responseType = typeof(AgentTask); }
+ internal A2AResponseResult(ListTasksResponse response) { _response = response; _responseType = typeof(ListTasksResponse); }
+ internal A2AResponseResult(AgentCard card) { _response = card; _responseType = typeof(AgentCard); }
+ internal A2AResponseResult(TaskPushNotificationConfig config) { _response = config; _responseType = typeof(TaskPushNotificationConfig); }
+ internal A2AResponseResult(ListTaskPushNotificationConfigResponse response) { _response = response; _responseType = typeof(ListTaskPushNotificationConfigResponse); }
+
+ public async Task ExecuteAsync(HttpContext httpContext)
+ {
+ httpContext.Response.ContentType = "application/json";
+ await JsonSerializer.SerializeAsync(httpContext.Response.Body, _response,
+ A2AJsonUtilities.DefaultOptions.GetTypeInfo(_responseType));
+ }
+}
+
+/// IResult for REST API Server-Sent Events streaming.
+internal sealed class A2AEventStreamResult : IResult
+{
+ private readonly IAsyncEnumerable _events;
+
+ internal A2AEventStreamResult(IAsyncEnumerable events) => _events = events;
+
+ public async Task ExecuteAsync(HttpContext httpContext)
+ {
+ httpContext.Response.ContentType = "text/event-stream";
+ httpContext.Response.Headers.CacheControl = "no-cache,no-store";
+ httpContext.Response.Headers.Pragma = "no-cache";
+ httpContext.Response.Headers.ContentEncoding = "identity";
+
+ var bufferingFeature = httpContext.Features.GetRequiredFeature();
+ bufferingFeature.DisableBuffering();
+
+ try
+ {
+ await foreach (var taskEvent in _events.WithCancellation(httpContext.RequestAborted))
+ {
+ var json = JsonSerializer.Serialize(taskEvent,
+ A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(StreamResponse)));
+ await httpContext.Response.BodyWriter.WriteAsync(
+ Encoding.UTF8.GetBytes($"data: {json}\n\n"), httpContext.RequestAborted);
+ await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Client disconnected — expected
+ }
+ catch (Exception)
+ {
+ // Stream error — response already started, best-effort error event
+ try
+ {
+ await httpContext.Response.BodyWriter.WriteAsync(
+ Encoding.UTF8.GetBytes("data: {\"error\":\"An internal error occurred during streaming.\"}\n\n"), httpContext.RequestAborted);
+ await httpContext.Response.BodyWriter.FlushAsync(httpContext.RequestAborted);
+ }
+ catch
+ {
+ // Response body no longer writable
+ }
+ }
+ }
+}
diff --git a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
index 6fdf8d3b..44f6588a 100644
--- a/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
+++ b/src/A2A.AspNetCore/A2AJsonRpcProcessor.cs
@@ -8,31 +8,22 @@ namespace A2A.AspNetCore;
///
/// Static processor class for handling A2A JSON-RPC requests in ASP.NET Core applications.
///
-///
-/// Provides methods for processing JSON-RPC 2.0 protocol requests including message sending,
-/// task operations, streaming responses, and push notification configuration.
-///
public static class A2AJsonRpcProcessor
{
- ///
- /// OpenTelemetry ActivitySource for tracing JSON-RPC processor operations.
- ///
- public static readonly ActivitySource ActivitySource = new("A2A.Processor", "1.0.0");
-
- ///
- /// Processes an incoming JSON-RPC request and routes it to the appropriate handler.
- ///
- ///
- /// Determines whether the request requires a single response or streaming response.
- /// based on the method name and dispatches accordingly.
- ///
- /// The task manager instance for handling A2A operations.
- /// Http request containing the JSON-RPC request body.
- /// The cancellation token to cancel the operation if needed.
- /// An HTTP result containing either a single JSON-RPC response or a streaming SSE response.
- internal static async Task ProcessRequestAsync(ITaskManager taskManager, HttpRequest request, CancellationToken cancellationToken)
+ internal static async Task ProcessRequestAsync(IA2ARequestHandler requestHandler, HttpRequest request, CancellationToken cancellationToken)
{
- using var activity = ActivitySource.StartActivity("HandleA2ARequest", ActivityKind.Server);
+ // Version negotiation: check A2A-Version header
+ var version = request.Headers["A2A-Version"].FirstOrDefault();
+ if (!string.IsNullOrEmpty(version) && version != "1.0" && version != "0.3")
+ {
+ return new JsonRpcResponseResult(JsonRpcResponse.CreateJsonRpcErrorResponse(
+ new JsonRpcId((string?)null),
+ new A2AException(
+ $"Protocol version '{version}' is not supported. Supported versions: 0.3, 1.0",
+ A2AErrorCode.VersionNotSupported)));
+ }
+
+ using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("HandleA2ARequest", ActivityKind.Server);
JsonRpcRequest? rpcRequest = null;
@@ -40,16 +31,15 @@ internal static async Task ProcessRequestAsync(ITaskManager taskManager
{
rpcRequest = (JsonRpcRequest?)await JsonSerializer.DeserializeAsync(request.Body, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcRequest)), cancellationToken).ConfigureAwait(false);
- activity?.AddTag("request.id", rpcRequest!.Id.ToString());
- activity?.AddTag("request.method", rpcRequest!.Method);
+ activity?.SetTag("request.id", rpcRequest!.Id.ToString());
+ activity?.SetTag("request.method", rpcRequest!.Method);
- // Dispatch based on return type
if (A2AMethods.IsStreamingMethod(rpcRequest!.Method))
{
- return StreamResponse(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken);
+ return StreamResponse(requestHandler, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken);
}
- return await SingleResponseAsync(taskManager, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false);
+ return await SingleResponseAsync(requestHandler, rpcRequest.Id, rpcRequest.Method, rpcRequest.Params, cancellationToken).ConfigureAwait(false);
}
catch (A2AException ex)
{
@@ -61,26 +51,13 @@ internal static async Task ProcessRequestAsync(ITaskManager taskManager
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
var errorId = rpcRequest?.Id ?? new JsonRpcId((string?)null);
- return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, ex.Message));
+ return new JsonRpcResponseResult(JsonRpcResponse.InternalErrorResponse(errorId, "An internal error occurred."));
}
}
- ///
- /// Processes JSON-RPC requests that require a single response (non-streaming).
- ///
- ///
- /// Handles methods like message sending, task retrieval, task cancellation,
- /// and push notification configuration operations.
- ///
- /// The task manager instance for handling A2A operations.
- /// The JSON-RPC request ID for response correlation.
- /// The JSON-RPC method name to execute.
- /// The JSON parameters for the method call.
- /// A cancellation token that can be used to cancel the operation.
- /// A JSON-RPC response result containing the operation result or error.
- internal static async Task SingleResponseAsync(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
+ internal static async Task SingleResponseAsync(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
- using var activity = ActivitySource.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
+ using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity($"SingleResponse/{method}", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());
activity?.SetTag("request.method", method);
@@ -92,34 +69,89 @@ internal static async Task SingleResponseAsync(ITaskManag
return new JsonRpcResponseResult(JsonRpcResponse.InvalidParamsResponse(requestId));
}
+ // For push notification methods, check if push notifications are supported
+ // BEFORE deserializing params. DeserializeAndValidate would throw InvalidParams
+ // for malformed requests, masking the PushNotificationNotSupported error.
+ if (A2AMethods.IsPushNotificationMethod(method))
+ {
+ try
+ {
+ await requestHandler.GetTaskPushNotificationConfigAsync(null!, cancellationToken).ConfigureAwait(false);
+ }
+ catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.PushNotificationNotSupported)
+ {
+ throw;
+ }
+ catch
+ {
+ // Any other exception means push notifications are supported;
+ // continue with normal deserialization and handling.
+ }
+ }
+
switch (method)
{
- case A2AMethods.MessageSend:
- var taskSendParams = DeserializeAndValidate(parameters.Value);
- var a2aResponse = await taskManager.SendMessageAsync(taskSendParams, cancellationToken).ConfigureAwait(false);
- response = JsonRpcResponse.CreateJsonRpcResponse(requestId, a2aResponse);
+ case A2AMethods.SendMessage:
+ var sendRequest = DeserializeAndValidate(parameters.Value);
+ var sendResult = await requestHandler.SendMessageAsync(sendRequest, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, sendResult);
+ break;
+ case A2AMethods.GetTask:
+ var getTaskRequest = DeserializeAndValidate(parameters.Value);
+ var agentTask = await requestHandler.GetTaskAsync(getTaskRequest, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, agentTask);
break;
- case A2AMethods.TaskGet:
- var taskIdParams = DeserializeAndValidate(parameters.Value);
- var getAgentTask = await taskManager.GetTaskAsync(taskIdParams, cancellationToken).ConfigureAwait(false);
- response = getAgentTask is null
- ? JsonRpcResponse.TaskNotFoundResponse(requestId)
- : JsonRpcResponse.CreateJsonRpcResponse(requestId, getAgentTask);
+ case A2AMethods.ListTasks:
+ var listTasksRequest = DeserializeAndValidate(parameters.Value);
+
+ // Validate pageSize: must be 1-100 if specified
+ if (listTasksRequest.PageSize is { } ps && (ps <= 0 || ps > 100))
+ {
+ throw new A2AException(
+ $"Invalid pageSize: {ps}. Must be between 1 and 100.",
+ A2AErrorCode.InvalidParams);
+ }
+
+ // Validate historyLength: must be >= 0 if specified
+ if (listTasksRequest.HistoryLength is { } hl && hl < 0)
+ {
+ throw new A2AException(
+ $"Invalid historyLength: {hl}. Must be non-negative.",
+ A2AErrorCode.InvalidParams);
+ }
+
+ var listResult = await requestHandler.ListTasksAsync(listTasksRequest, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, listResult);
break;
- case A2AMethods.TaskCancel:
- var taskIdParamsCancel = DeserializeAndValidate(parameters.Value);
- var cancelledTask = await taskManager.CancelTaskAsync(taskIdParamsCancel, cancellationToken).ConfigureAwait(false);
+ case A2AMethods.CancelTask:
+ var cancelRequest = DeserializeAndValidate(parameters.Value);
+ var cancelledTask = await requestHandler.CancelTaskAsync(cancelRequest, cancellationToken).ConfigureAwait(false);
response = JsonRpcResponse.CreateJsonRpcResponse(requestId, cancelledTask);
break;
- case A2AMethods.TaskPushNotificationConfigSet:
- var taskPushNotificationConfig = DeserializeAndValidate(parameters.Value);
- var setConfig = await taskManager.SetPushNotificationAsync(taskPushNotificationConfig, cancellationToken).ConfigureAwait(false);
- response = JsonRpcResponse.CreateJsonRpcResponse(requestId, setConfig);
+ case A2AMethods.CreateTaskPushNotificationConfig:
+ var createPnConfig = DeserializeAndValidate(parameters.Value);
+ var createdConfig = await requestHandler.CreateTaskPushNotificationConfigAsync(createPnConfig, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, createdConfig);
+ break;
+ case A2AMethods.GetTaskPushNotificationConfig:
+ var getPnConfig = DeserializeAndValidate(parameters.Value);
+ var gotConfig = await requestHandler.GetTaskPushNotificationConfigAsync(getPnConfig, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, gotConfig);
+ break;
+ case A2AMethods.ListTaskPushNotificationConfig:
+ var listPnConfig = DeserializeAndValidate(parameters.Value);
+ var listPnResult = await requestHandler.ListTaskPushNotificationConfigAsync(listPnConfig, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, listPnResult);
+ break;
+ case A2AMethods.DeleteTaskPushNotificationConfig:
+ var deletePnConfig = DeserializeAndValidate(parameters.Value);
+ await requestHandler.DeleteTaskPushNotificationConfigAsync(deletePnConfig, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, (object?)null);
break;
- case A2AMethods.TaskPushNotificationConfigGet:
- var notificationConfigParams = DeserializeAndValidate(parameters.Value);
- var getConfig = await taskManager.GetPushNotificationAsync(notificationConfigParams, cancellationToken).ConfigureAwait(false);
- response = JsonRpcResponse.CreateJsonRpcResponse(requestId, getConfig);
+ case A2AMethods.GetExtendedAgentCard:
+ var getCardRequest = DeserializeAndValidate(parameters.Value);
+ var extCard = await requestHandler.GetExtendedAgentCardAsync(getCardRequest, cancellationToken).ConfigureAwait(false);
+ response = JsonRpcResponse.CreateJsonRpcResponse(requestId, extCard);
break;
default:
response = JsonRpcResponse.MethodNotFoundResponse(requestId);
@@ -138,39 +170,25 @@ private static T DeserializeAndValidate(JsonElement jsonParamValue) where T :
}
catch (JsonException ex)
{
- // Provide more specific error information about why parameter deserialization failed
- throw new A2AException($"Invalid parameters for {typeof(T).Name}: {ex.Message}", ex, A2AErrorCode.InvalidParams);
+ throw new A2AException($"Invalid parameters: request body could not be deserialized as {typeof(T).Name}.", ex, A2AErrorCode.InvalidParams);
}
- switch (parms)
+ if (parms is null)
{
- case null:
- throw new A2AException($"Failed to deserialize parameters as {typeof(T).Name}", A2AErrorCode.InvalidParams);
- case MessageSendParams messageSendParams when messageSendParams.Message.Parts.Count == 0:
- throw new A2AException("Message parts cannot be empty", A2AErrorCode.InvalidParams);
- case TaskQueryParams taskQueryParams when taskQueryParams.HistoryLength < 0:
- throw new A2AException("History length cannot be negative", A2AErrorCode.InvalidParams);
- default:
- return parms;
+ throw new A2AException($"Failed to deserialize parameters as {typeof(T).Name}", A2AErrorCode.InvalidParams);
}
+
+ if (parms is SendMessageRequest sendMsgRequest && sendMsgRequest.Message.Parts.Count == 0)
+ {
+ throw new A2AException("Message parts cannot be empty", A2AErrorCode.InvalidParams);
+ }
+
+ return parms;
}
- ///
- /// Processes JSON-RPC requests that require streaming responses using Server-Sent Events.
- ///
- ///
- /// Handles methods like task resubscription and streaming message sending that return
- /// continuous streams of events rather than single responses.
- ///
- /// The task manager instance for handling streaming A2A operations.
- /// The JSON-RPC request ID for response correlation.
- /// The JSON-RPC streaming method name to execute.
- /// The JSON parameters for the streaming method call.
- /// A cancellation token that can be used to cancel the operation.
- /// An HTTP result that streams JSON-RPC responses as Server-Sent Events or an error response.
- internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
+ internal static IResult StreamResponse(IA2ARequestHandler requestHandler, JsonRpcId requestId, string method, JsonElement? parameters, CancellationToken cancellationToken)
{
- using var activity = ActivitySource.StartActivity("StreamResponse", ActivityKind.Server);
+ using var activity = A2AAspNetCoreDiagnostics.Source.StartActivity("StreamResponse", ActivityKind.Server);
activity?.SetTag("request.id", requestId.ToString());
if (parameters == null)
@@ -181,17 +199,17 @@ internal static IResult StreamResponse(ITaskManager taskManager, JsonRpcId reque
switch (method)
{
- case A2AMethods.TaskSubscribe:
- var taskIdParams = DeserializeAndValidate(parameters.Value);
- var taskEvents = taskManager.SubscribeToTaskAsync(taskIdParams, cancellationToken);
+ case A2AMethods.SubscribeToTask:
+ var subscribeRequest = DeserializeAndValidate(parameters.Value);
+ var taskEvents = requestHandler.SubscribeToTaskAsync(subscribeRequest, cancellationToken);
return new JsonRpcStreamedResult(taskEvents, requestId);
- case A2AMethods.MessageStream:
- var taskSendParams = DeserializeAndValidate(parameters.Value);
- var sendEvents = taskManager.SendMessageStreamingAsync(taskSendParams, cancellationToken);
+ case A2AMethods.SendStreamingMessage:
+ var sendRequest = DeserializeAndValidate(parameters.Value);
+ var sendEvents = requestHandler.SendStreamingMessageAsync(sendRequest, cancellationToken);
return new JsonRpcStreamedResult(sendEvents, requestId);
default:
activity?.SetStatus(ActivityStatusCode.Error, "Invalid method");
return new JsonRpcResponseResult(JsonRpcResponse.MethodNotFoundResponse(requestId));
}
}
-}
\ No newline at end of file
+}
diff --git a/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs b/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs
new file mode 100644
index 00000000..e009f0f7
--- /dev/null
+++ b/src/A2A.AspNetCore/A2AServiceCollectionExtensions.cs
@@ -0,0 +1,50 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace A2A.AspNetCore;
+
+///
+/// DI registration extensions for the A2A easy-path agent hosting.
+///
+public static class A2AServiceCollectionExtensions
+{
+ ///
+ /// Registers an and supporting infrastructure
+ /// for the easy-path agent hosting pattern. Call
+ /// to map the endpoint.
+ ///
+ /// The agent handler type implementing .
+ /// The service collection.
+ /// The agent card describing this agent's capabilities.
+ /// Optional callback to configure .
+ /// The service collection for chaining.
+ public static IServiceCollection AddA2AAgent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(
+ this IServiceCollection services,
+ AgentCard agentCard,
+ Action? configureOptions = null)
+ where THandler : class, IAgentHandler
+ {
+ services.AddSingleton();
+ services.AddSingleton(agentCard);
+
+ var options = new A2AServerOptions();
+ configureOptions?.Invoke(options);
+ services.AddSingleton(options);
+
+ services.TryAddSingleton();
+
+ services.TryAddSingleton();
+
+ services.TryAddSingleton(sp =>
+ new A2AServer(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ sp.GetRequiredService()));
+
+ return services;
+ }
+}
diff --git a/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs b/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs
deleted file mode 100644
index 20e29498..00000000
--- a/src/A2A.AspNetCore/Caching/DistributedCacheTaskStore.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using Microsoft.Extensions.Caching.Distributed;
-using System.Text.Json;
-
-namespace A2A.AspNetCore.Caching;
-
-///
-/// Distributed cache implementation of task store.
-///
-/// The used to store tasks.
-public class DistributedCacheTaskStore(IDistributedCache cache)
- : ITaskStore
-{
- ///
- public async Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (string.IsNullOrEmpty(taskId))
- {
- throw new ArgumentNullException(nameof(taskId));
- }
- var bytes = await cache.GetAsync(BuildTaskCacheKey(taskId), cancellationToken).ConfigureAwait(false);
- if (bytes == null || bytes.Length < 1)
- {
- return null;
- }
- return JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.AgentTask);
- }
-
- ///
- public async Task GetPushNotificationAsync(string taskId, string notificationConfigId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (string.IsNullOrEmpty(taskId))
- {
- throw new ArgumentNullException(nameof(taskId));
- }
- var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(taskId), cancellationToken).ConfigureAwait(false);
- if (bytes == null || bytes.Length < 1)
- {
- return null;
- }
- var pushNotificationConfigs = JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig);
- if (pushNotificationConfigs == null || pushNotificationConfigs.Count < 1)
- {
- return null;
- }
- return pushNotificationConfigs.FirstOrDefault(config => config.PushNotificationConfig.Id == notificationConfigId);
- }
-
- ///
- public async Task> GetPushNotificationsAsync(string taskId, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(taskId), cancellationToken).ConfigureAwait(false);
- if (bytes == null || bytes.Length < 1)
- {
- return [];
- }
- return JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig) ?? [];
- }
-
- ///
- public async Task UpdateStatusAsync(string taskId, TaskState status, AgentMessage? message = null, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (string.IsNullOrEmpty(taskId))
- {
- throw new ArgumentNullException(nameof(taskId));
- }
- var cacheKey = BuildTaskCacheKey(taskId);
- var bytes = await cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
- if (bytes == null || bytes.Length < 1)
- {
- throw new A2AException($"Task with ID '{taskId}' not found in cache.", A2AErrorCode.TaskNotFound);
- }
- var task = JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.AgentTask) ?? throw new InvalidDataException("Task data from cache is corrupt.");
- task.Status = task.Status with
- {
- State = status,
- Message = message,
- Timestamp = DateTimeOffset.UtcNow
- };
- bytes = JsonSerializer.SerializeToUtf8Bytes(task, A2AJsonUtilities.JsonContext.Default.AgentTask);
- await cache.SetAsync(cacheKey, bytes, cancellationToken).ConfigureAwait(false);
- return task.Status;
- }
-
- ///
- public async Task SetTaskAsync(AgentTask task, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- var bytes = JsonSerializer.SerializeToUtf8Bytes(task, A2AJsonUtilities.JsonContext.Default.AgentTask);
- await cache.SetAsync(BuildTaskCacheKey(task.Id), bytes, cancellationToken).ConfigureAwait(false);
- }
-
- ///
- public async Task SetPushNotificationConfigAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (pushNotificationConfig is null)
- {
- throw new ArgumentNullException(nameof(pushNotificationConfig));
- }
-
- if (string.IsNullOrWhiteSpace(pushNotificationConfig.TaskId))
- {
- throw new ArgumentException("Task ID cannot be null or empty.", nameof(pushNotificationConfig));
- }
- var bytes = await cache.GetAsync(BuildPushNotificationsCacheKey(pushNotificationConfig.TaskId), cancellationToken).ConfigureAwait(false);
- var pushNotificationConfigs = bytes == null ? [] : JsonSerializer.Deserialize(bytes, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig) ?? [];
- pushNotificationConfigs.RemoveAll(c => c.PushNotificationConfig.Id == pushNotificationConfig.PushNotificationConfig.Id);
- pushNotificationConfigs.Add(pushNotificationConfig);
- bytes = JsonSerializer.SerializeToUtf8Bytes(pushNotificationConfigs, A2AJsonUtilities.JsonContext.Default.ListTaskPushNotificationConfig);
- await cache.SetAsync(BuildPushNotificationsCacheKey(pushNotificationConfig.TaskId), bytes, cancellationToken).ConfigureAwait(false);
- }
-
- static string BuildTaskCacheKey(string taskId) => $"task:{taskId}";
-
- static string BuildPushNotificationsCacheKey(string taskId) => $"task-push-notification:{taskId}";
-}
diff --git a/src/A2A.AspNetCore/JsonRpcResponseResult.cs b/src/A2A.AspNetCore/JsonRpcResponseResult.cs
index a581ec7c..56734181 100644
--- a/src/A2A.AspNetCore/JsonRpcResponseResult.cs
+++ b/src/A2A.AspNetCore/JsonRpcResponseResult.cs
@@ -10,7 +10,7 @@ namespace A2A.AspNetCore;
/// Implements IResult to provide custom serialization of JSON-RPC response objects
/// with appropriate HTTP status codes based on success or error conditions.
///
-public class JsonRpcResponseResult : IResult
+public sealed class JsonRpcResponseResult : IResult
{
private readonly JsonRpcResponse jsonRpcResponse;
diff --git a/src/A2A.AspNetCore/JsonRpcStreamedResult.cs b/src/A2A.AspNetCore/JsonRpcStreamedResult.cs
index f5588d0b..8f1790e8 100644
--- a/src/A2A.AspNetCore/JsonRpcStreamedResult.cs
+++ b/src/A2A.AspNetCore/JsonRpcStreamedResult.cs
@@ -1,61 +1,72 @@
-using Microsoft.AspNetCore.Http;
-using System.Net.ServerSentEvents;
-using System.Text.Encodings.Web;
-using System.Text.Json;
-
-namespace A2A.AspNetCore;
-
-///
-/// Result type for streaming JSON-RPC responses as Server-Sent Events (SSE) in HTTP responses.
-///
-///
-/// Implements IResult to provide real-time streaming of JSON-RPC responses for continuous
-/// event streams like task updates, status changes, and artifact notifications.
-///
-public class JsonRpcStreamedResult : IResult
-{
- private readonly IAsyncEnumerable _events;
- private readonly JsonRpcId requestId;
-
- ///
- /// Initializes a new instance of the JsonRpcStreamedResult class.
- ///
- /// The async enumerable stream of A2A events to send as Server-Sent Events.
- /// The JSON-RPC request ID used for correlating responses with the original request.
- public JsonRpcStreamedResult(IAsyncEnumerable events, JsonRpcId requestId)
- {
- ArgumentNullException.ThrowIfNull(events);
-
- _events = events;
- this.requestId = requestId;
- }
-
- ///
- /// Executes the result by streaming JSON-RPC responses as Server-Sent Events to the HTTP response.
- ///
- ///
- /// Sets appropriate SSE headers, wraps each A2A event in a JSON-RPC response format,
- /// and streams them using the SSE protocol with proper formatting and encoding.
- ///
- /// The HTTP context to stream the responses to.
- /// A task representing the asynchronous streaming operation.
- public async Task ExecuteAsync(HttpContext httpContext)
- {
- ArgumentNullException.ThrowIfNull(httpContext);
-
- httpContext.Response.StatusCode = StatusCodes.Status200OK;
- httpContext.Response.ContentType = "text/event-stream";
- httpContext.Response.Headers.Append("Cache-Control", "no-cache");
-
- var responseTypeInfo = A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse));
- await SseFormatter.WriteAsync(
- _events.Select(e => new SseItem(JsonRpcResponse.CreateJsonRpcResponse(requestId, e))),
- httpContext.Response.Body,
- (item, writer) =>
- {
- using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
- JsonSerializer.Serialize(json, item.Data, responseTypeInfo);
- },
- httpContext.RequestAborted).ConfigureAwait(false);
- }
+using Microsoft.AspNetCore.Http;
+using System.Net.ServerSentEvents;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+
+namespace A2A.AspNetCore;
+
+///
+/// Result type for streaming JSON-RPC responses as Server-Sent Events (SSE) in HTTP responses.
+///
+public sealed class JsonRpcStreamedResult : IResult
+{
+ private readonly IAsyncEnumerable _events;
+ private readonly JsonRpcId _requestId;
+
+ /// Initializes a new instance of the class.
+ /// The stream of response events.
+ /// The JSON-RPC request ID.
+ public JsonRpcStreamedResult(IAsyncEnumerable events, JsonRpcId requestId)
+ {
+ ArgumentNullException.ThrowIfNull(events);
+ _events = events;
+ _requestId = requestId;
+ }
+
+ ///
+ public async Task ExecuteAsync(HttpContext httpContext)
+ {
+ ArgumentNullException.ThrowIfNull(httpContext);
+
+ httpContext.Response.StatusCode = StatusCodes.Status200OK;
+ httpContext.Response.ContentType = "text/event-stream";
+ httpContext.Response.Headers.Append("Cache-Control", "no-cache");
+
+ var responseTypeInfo = A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcResponse));
+ try
+ {
+ await SseFormatter.WriteAsync(
+ _events.Select(e => new SseItem(JsonRpcResponse.CreateJsonRpcResponse(_requestId, e))),
+ httpContext.Response.Body,
+ (item, writer) =>
+ {
+ using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping });
+ JsonSerializer.Serialize(json, item.Data, responseTypeInfo);
+ },
+ httpContext.RequestAborted).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Client disconnected — expected
+ }
+ catch (Exception)
+ {
+ // Stream error — response already started, cannot change status code.
+ // Best effort: write an error event if the response body is still writable.
+ try
+ {
+ var errorResponse = JsonRpcResponse.InternalErrorResponse(
+ _requestId, "An internal error occurred during streaming.");
+ var errorJson = JsonSerializer.Serialize(errorResponse, responseTypeInfo);
+ var errorBytes = Encoding.UTF8.GetBytes($"data: {errorJson}\n\n");
+ await httpContext.Response.Body.WriteAsync(errorBytes, httpContext.RequestAborted);
+ await httpContext.Response.Body.FlushAsync(httpContext.RequestAborted);
+ }
+ catch
+ {
+ // Response body is no longer writable — silently abandon
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/src/A2A.V0_3/A2A.V0_3.csproj b/src/A2A.V0_3/A2A.V0_3.csproj
new file mode 100644
index 00000000..aa1f42f4
--- /dev/null
+++ b/src/A2A.V0_3/A2A.V0_3.csproj
@@ -0,0 +1,46 @@
+
+
+
+ net10.0;net8.0
+ true
+
+
+ A2A.V0_3
+ .NET SDK for the Agent2Agent (A2A) protocol v0.3 (legacy).
+ Agent2Agent;a2a;agent;ai;llm;aspnetcore
+ README.md
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/A2A.V0_3/A2AErrorCode.cs b/src/A2A.V0_3/A2AErrorCode.cs
new file mode 100644
index 00000000..c289cd6a
--- /dev/null
+++ b/src/A2A.V0_3/A2AErrorCode.cs
@@ -0,0 +1,57 @@
+namespace A2A.V0_3;
+
+///
+/// Standard JSON-RPC error codes used in A2A protocol.
+///
+public enum A2AErrorCode
+{
+ ///
+ /// Task not found - The specified task does not exist.
+ ///
+ TaskNotFound = -32001,
+
+ ///
+ /// Task not cancelable - The task cannot be canceled.
+ ///
+ TaskNotCancelable = -32002,
+
+ ///
+ /// Push notification not supported - Push notifications are not supported.
+ ///
+ PushNotificationNotSupported = -32003,
+
+ ///
+ /// Unsupported operation - The requested operation is not supported.
+ ///
+ UnsupportedOperation = -32004,
+
+ ///
+ /// Content type not supported - The content type is not supported.
+ ///
+ ContentTypeNotSupported = -32005,
+
+ ///
+ /// Invalid request - The JSON is not a valid Request object.
+ ///
+ InvalidRequest = -32600,
+
+ ///
+ /// Method not found - The method does not exist or is not available.
+ ///
+ MethodNotFound = -32601,
+
+ ///
+ /// Invalid params - Invalid method parameters.
+ ///
+ InvalidParams = -32602,
+
+ ///
+ /// Internal error - Internal JSON-RPC error.
+ ///
+ InternalError = -32603,
+
+ ///
+ /// Parse error - Invalid JSON received.
+ ///
+ ParseError = -32700,
+}
\ No newline at end of file
diff --git a/src/A2A.V0_3/A2AException.cs b/src/A2A.V0_3/A2AException.cs
new file mode 100644
index 00000000..57666560
--- /dev/null
+++ b/src/A2A.V0_3/A2AException.cs
@@ -0,0 +1,78 @@
+namespace A2A.V0_3;
+
+///
+/// Represents an exception that is thrown when an Agent-to-Agent (A2A) protocol error occurs.
+///
+///
+/// This exception is used to represent failures to do with protocol-level concerns, such as invalid JSON-RPC requests,
+/// invalid parameters, or internal errors. It is not intended to be used for application-level errors.
+/// or from a may be
+/// propagated to the remote endpoint; sensitive information should not be included. If sensitive details need
+/// to be included, a different exception type should be used.
+///
+public class A2AException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public A2AException()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The message that describes the error.
+ public A2AException(string message) : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception.
+ ///
+ /// The message that describes the error.
+ /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified.
+ public A2AException(string message, Exception? innerException) : base(message, innerException)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message and JSON-RPC error code.
+ ///
+ /// The message that describes the error.
+ /// A .
+ public A2AException(string message, A2AErrorCode errorCode) : this(message, null, errorCode)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message, inner exception, and JSON-RPC error code.
+ ///
+ /// The message that describes the error.
+ /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified.
+ /// A .
+ public A2AException(string message, Exception? innerException, A2AErrorCode errorCode) : base(message, innerException)
+ {
+ ErrorCode = errorCode;
+ }
+
+ ///
+ /// Gets the error code associated with this exception.
+ ///
+ ///
+ /// This property contains a standard JSON-RPC error code as defined in the A2A specification. Available error codes include:
+ ///
+ /// - -32600: Invalid request - The JSON is not a valid Request object
+ /// - -32601: Method not found - The method does not exist or is not available
+ /// - -32602: Invalid params - Invalid method parameters
+ /// - -32603: Internal error - Internal JSON-RPC error
+ /// - -32700: Parse error - Invalid JSON received
+ /// - -32001: Task not found - The specified task does not exist
+ /// - -32002: Task not cancelable - The task cannot be canceled
+ /// - -32003: Push notification not supported - Push notifications are not supported
+ /// - -32004: Unsupported operation - The requested operation is not supported
+ /// - -32005: Content type not supported - The content type is not supported
+ ///
+ ///
+ public A2AErrorCode ErrorCode { get; } = A2AErrorCode.InternalError;
+}
\ No newline at end of file
diff --git a/src/A2A.V0_3/A2AExceptionExtensions.cs b/src/A2A.V0_3/A2AExceptionExtensions.cs
new file mode 100644
index 00000000..e9ee1d80
--- /dev/null
+++ b/src/A2A.V0_3/A2AExceptionExtensions.cs
@@ -0,0 +1,84 @@
+namespace A2A.V0_3
+{
+ ///
+ /// Provides extension methods for .
+ ///
+ public static class A2AExceptionExtensions
+ {
+ private const string RequestIdKey = "RequestId";
+
+ ///
+ /// Associates a request ID with the specified .
+ ///
+ /// The to associate the request ID with.
+ /// The request ID to associate with the exception. Can be null.
+ /// The same instance with the request ID stored in its Data collection.
+ ///
+ /// 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 method.
+ /// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios.
+ ///
+ public static A2AException WithRequestId(this A2AException exception, string? requestId)
+ {
+ if (exception is null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ exception.Data[RequestIdKey] = requestId;
+
+ return exception;
+ }
+
+ ///
+ /// Associates a request ID with the specified .
+ ///
+ /// The to associate the request ID with.
+ /// The request ID to associate with the exception.
+ /// The same instance with the request ID stored in its Data collection.
+ ///
+ /// 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 method.
+ /// This is useful for correlating exceptions with specific HTTP requests in logging and debugging scenarios.
+ ///
+ public static A2AException WithRequestId(this A2AException exception, JsonRpcId requestId)
+ {
+ if (exception is null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ exception.Data[RequestIdKey] = requestId.ToString();
+
+ return exception;
+ }
+
+ ///
+ /// Retrieves the request ID associated with the specified .
+ ///
+ /// The to retrieve the request ID from.
+ ///
+ /// The request ID associated with the exception if one was previously set using ,
+ /// or null if no request ID was set or if the stored value is not a string.
+ ///
+ ///
+ /// This method retrieves the request ID from the exception's Data collection using the key "RequestId".
+ /// If the stored value is not a string or doesn't exist, null is returned.
+ /// This method is typically used in exception handlers to correlate exceptions with specific HTTP requests.
+ ///
+ public static string? GetRequestId(this A2AException exception)
+ {
+ if (exception is null)
+ {
+ throw new ArgumentNullException(nameof(exception));
+ }
+
+ if (exception.Data[RequestIdKey] is string requestIdString)
+ {
+ return requestIdString;
+ }
+
+ return null;
+ }
+ }
+}
diff --git a/src/A2A/A2AJsonConverter.cs b/src/A2A.V0_3/A2AJsonConverter.cs
similarity index 97%
rename from src/A2A/A2AJsonConverter.cs
rename to src/A2A.V0_3/A2AJsonConverter.cs
index 6d50da9a..24ac2b78 100644
--- a/src/A2A/A2AJsonConverter.cs
+++ b/src/A2A.V0_3/A2AJsonConverter.cs
@@ -1,129 +1,129 @@
-using System.Diagnostics;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Text.Json.Serialization.Metadata;
-
-namespace A2A
-{
- internal interface IA2AJsonConverter;
-
- ///
- /// Provides a base JSON converter for types in the A2A protocol, enabling custom error handling and
- /// safe delegation to source-generated or built-in converters for the target type.
- ///
- /// The type to convert.
- internal class A2AJsonConverter : JsonConverter, IA2AJsonConverter where T : notnull
- {
- private static JsonSerializerOptions? _serializerOptionsWithoutThisConverter;
- private static JsonSerializerOptions? _outsideSerializerOptions;
-
- ///
- /// Reads and converts the JSON to type .
- ///
- /// The reader to read from.
- /// The type to convert.
- /// The serializer options to use.
- /// The deserialized value of type .
- ///
- /// Thrown when deserialization fails, wrapping the original exception and providing an error code.
- ///
- public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- using var d = JsonDocument.ParseValue(ref reader);
- return DeserializeImpl(typeToConvert, GetSafeOptions(options), d);
- }
-
- ///
- /// Deserializes the specified to type using the provided options.
- ///
- /// The type to convert.
- /// The serializer options to use.
- /// The JSON document to deserialize.
- /// The deserialized value of type .
- protected virtual T? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) => document.Deserialize((JsonTypeInfo)options.GetTypeInfo(typeToConvert));
-
- ///
- /// Writes the specified value as JSON.
- ///
- /// The writer to write to.
- /// The value to write.
- /// The serializer options to use.
- ///
- /// Thrown when serialization fails, wrapping the original exception and providing an error code.
- ///
- public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
- {
- try
- {
- if (value is null)
- {
- writer.WriteNullValue();
- return;
- }
-
- SerializeImpl(writer, value, GetSafeOptions(options));
- }
- catch (Exception e)
- {
- throw new A2AException($"Failed to serialize {typeof(T).Name}: {e.Message}", e, A2AErrorCode.InternalError);
- }
- }
-
- ///
- /// Serializes the specified value to JSON using the provided options.
- ///
- /// The writer to write to.
- /// The value to serialize.
- /// The serializer options to use.
- protected virtual void SerializeImpl(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, (JsonTypeInfo)options.GetTypeInfo(value.GetType()));
-
- ///
- /// Returns a copy of the provided with this
- /// removed from its chain.
- ///
- ///
- /// This converter delegates to the source-generated or built-in converter for by
- /// resolving from the options. If the original options are used as-is, this converter
- /// would be selected again, causing infinite recursion and a stack overflow. Cloning the options and removing
- /// this converter ensures the underlying, "real" converter handles serialization/deserialization of .
- ///
- /// The returned options are cached per closed generic type to avoid repeated allocations. The cache assumes a
- /// stable options instance; if multiple distinct options are used, the first encountered configuration is captured.
- ///
- /// Caller options; used as a template for the safe copy.
- /// A copy of the options that can safely resolve the underlying converter for .
- private static JsonSerializerOptions GetSafeOptions(JsonSerializerOptions options)
- {
- if (_serializerOptionsWithoutThisConverter is null)
- {
- // keep reeference to original options for cache validation
- _outsideSerializerOptions = options;
-
- // Clone options so we can modify the converters chain safely
- var baseOptions = new JsonSerializerOptions(options);
-
- // Remove this converter so base/source-generated converter handles T, otherwise stack overflow
- for (int i = baseOptions.Converters.Count - 1; i >= 0; i--)
- {
- if (baseOptions.Converters[i] is A2AJsonConverter)
- {
- baseOptions.Converters.RemoveAt(i);
- break;
- }
- }
-
- _serializerOptionsWithoutThisConverter = baseOptions;
- }
- else if (_outsideSerializerOptions != options && options != _serializerOptionsWithoutThisConverter)
- {
- // Unexpected!!! This caching is based on promise that A2A will use only ONE instance of SerializerOptions
- // and we can therefore cache modified SerializerOptions without dealing with invalidation and pairing.
- // Since this is possible only by internal code, some recent code changes must have had broke this promise
- Debug.Fail("This should never happen");
- throw new InvalidOperationException();
- }
-
- return _serializerOptionsWithoutThisConverter;
- }
- }
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace A2A.V0_3
+{
+ internal interface IA2AJsonConverter;
+
+ ///
+ /// Provides a base JSON converter for types in the A2A protocol, enabling custom error handling and
+ /// safe delegation to source-generated or built-in converters for the target type.
+ ///
+ /// The type to convert.
+ internal class A2AJsonConverter : JsonConverter, IA2AJsonConverter where T : notnull
+ {
+ private static JsonSerializerOptions? _serializerOptionsWithoutThisConverter;
+ private static JsonSerializerOptions? _outsideSerializerOptions;
+
+ ///
+ /// Reads and converts the JSON to type .
+ ///
+ /// The reader to read from.
+ /// The type to convert.
+ /// The serializer options to use.
+ /// The deserialized value of type .
+ ///
+ /// Thrown when deserialization fails, wrapping the original exception and providing an error code.
+ ///
+ public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var d = JsonDocument.ParseValue(ref reader);
+ return DeserializeImpl(typeToConvert, GetSafeOptions(options), d);
+ }
+
+ ///
+ /// Deserializes the specified to type using the provided options.
+ ///
+ /// The type to convert.
+ /// The serializer options to use.
+ /// The JSON document to deserialize.
+ /// The deserialized value of type .
+ protected virtual T? DeserializeImpl(Type typeToConvert, JsonSerializerOptions options, JsonDocument document) => document.Deserialize((JsonTypeInfo)options.GetTypeInfo(typeToConvert));
+
+ ///
+ /// Writes the specified value as JSON.
+ ///
+ /// The writer to write to.
+ /// The value to write.
+ /// The serializer options to use.
+ ///
+ /// Thrown when serialization fails, wrapping the original exception and providing an error code.
+ ///
+ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
+ {
+ try
+ {
+ if (value is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ SerializeImpl(writer, value, GetSafeOptions(options));
+ }
+ catch (Exception e)
+ {
+ throw new A2AException($"Failed to serialize {typeof(T).Name}: {e.Message}", e, A2AErrorCode.InternalError);
+ }
+ }
+
+ ///
+ /// Serializes the specified value to JSON using the provided options.
+ ///
+ /// The writer to write to.
+ /// The value to serialize.
+ /// The serializer options to use.
+ protected virtual void SerializeImpl(Utf8JsonWriter writer, T value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, value, (JsonTypeInfo)options.GetTypeInfo(value.GetType()));
+
+ ///
+ /// Returns a copy of the provided with this
+ /// removed from its chain.
+ ///
+ ///
+ /// This converter delegates to the source-generated or built-in converter for by
+ /// resolving from the options. If the original options are used as-is, this converter
+ /// would be selected again, causing infinite recursion and a stack overflow. Cloning the options and removing
+ /// this converter ensures the underlying, "real" converter handles serialization/deserialization of .
+ ///
+ /// The returned options are cached per closed generic type to avoid repeated allocations. The cache assumes a
+ /// stable options instance; if multiple distinct options are used, the first encountered configuration is captured.
+ ///
+ /// Caller options; used as a template for the safe copy.
+ /// A copy of the options that can safely resolve the underlying converter for .
+ private static JsonSerializerOptions GetSafeOptions(JsonSerializerOptions options)
+ {
+ if (_serializerOptionsWithoutThisConverter is null)
+ {
+ // keep reeference to original options for cache validation
+ _outsideSerializerOptions = options;
+
+ // Clone options so we can modify the converters chain safely
+ var baseOptions = new JsonSerializerOptions(options);
+
+ // Remove this converter so base/source-generated converter handles T, otherwise stack overflow
+ for (int i = baseOptions.Converters.Count - 1; i >= 0; i--)
+ {
+ if (baseOptions.Converters[i] is A2AJsonConverter)
+ {
+ baseOptions.Converters.RemoveAt(i);
+ break;
+ }
+ }
+
+ _serializerOptionsWithoutThisConverter = baseOptions;
+ }
+ else if (_outsideSerializerOptions != options && options != _serializerOptionsWithoutThisConverter)
+ {
+ // Unexpected!!! This caching is based on promise that A2A will use only ONE instance of SerializerOptions
+ // and we can therefore cache modified SerializerOptions without dealing with invalidation and pairing.
+ // Since this is possible only by internal code, some recent code changes must have had broke this promise
+ Debug.Fail("This should never happen");
+ throw new InvalidOperationException();
+ }
+
+ return _serializerOptionsWithoutThisConverter;
+ }
+ }
}
\ No newline at end of file
diff --git a/src/A2A.V0_3/A2AJsonUtilities.cs b/src/A2A.V0_3/A2AJsonUtilities.cs
new file mode 100644
index 00000000..9fc44441
--- /dev/null
+++ b/src/A2A.V0_3/A2AJsonUtilities.cs
@@ -0,0 +1,82 @@
+using Microsoft.Extensions.AI;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace A2A.V0_3;
+
+///
+/// Provides a collection of utility methods for working with JSON data in the context of A2A.
+///
+public static partial class A2AJsonUtilities
+{
+ ///
+ /// Gets the singleton used as the default in JSON serialization operations.
+ ///
+ ///
+ ///
+ /// For Native AOT or applications disabling , this instance
+ /// includes source generated contracts for all common exchange types contained in the A2A library.
+ ///
+ ///
+ /// It additionally turns on the following settings:
+ ///
+ /// - Enables defaults.
+ /// - Enables as the default ignore condition for properties.
+ /// - Enables as the default number handling for number types.
+ /// - Enables AllowOutOfOrderMetadataProperties to allow for type discriminators anywhere in a JSON payload.
+ ///
+ ///
+ ///
+ public static JsonSerializerOptions DefaultOptions => defaultOptions.Value;
+
+ private static Lazy defaultOptions = new(() =>
+ {
+ // Clone source-generated options so we can customize
+ var opts = new JsonSerializerOptions(JsonContext.Default.Options)
+ {
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // optional: keep '+' unescaped
+ };
+
+ // Chain with all supported types from MEAI.
+ opts.TypeInfoResolverChain.Add(AIJsonUtilities.DefaultOptions.TypeInfoResolver!);
+
+ // Register custom converters at options-level (not attributes)
+ opts.Converters.Add(new A2AJsonConverter());
+ opts.Converters.Add(new FileContent.Converter());
+
+ opts.MakeReadOnly();
+ return opts;
+ });
+
+ // Keep in sync with CreateDefaultOptions above.
+ [JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ NumberHandling = JsonNumberHandling.AllowReadingFromString,
+ AllowOutOfOrderMetadataProperties = true)]
+
+ // JSON-RPC
+ [JsonSerializable(typeof(JsonRpcError))]
+ [JsonSerializable(typeof(JsonRpcId))]
+ [JsonSerializable(typeof(JsonRpcRequest))]
+ [JsonSerializable(typeof(JsonRpcResponse))]
+ [JsonSerializable(typeof(Dictionary))]
+
+ // A2A
+ [JsonSerializable(typeof(A2AEvent))]
+ [JsonSerializable(typeof(A2AResponse))]
+ [JsonSerializable(typeof(AgentCard))]
+ [JsonSerializable(typeof(AgentTask))]
+ [JsonSerializable(typeof(GetTaskPushNotificationConfigParams))]
+ [JsonSerializable(typeof(List))]
+ [JsonSerializable(typeof(MessageSendParams))]
+ [JsonSerializable(typeof(PushNotificationAuthenticationInfo))]
+ [JsonSerializable(typeof(PushNotificationConfig))]
+ [JsonSerializable(typeof(TaskIdParams))]
+ [JsonSerializable(typeof(TaskPushNotificationConfig))]
+ [JsonSerializable(typeof(TaskQueryParams))]
+
+ [ExcludeFromCodeCoverage]
+ internal sealed partial class JsonContext : JsonSerializerContext;
+}
diff --git a/src/A2A/BaseKindDiscriminatorConverter.cs b/src/A2A.V0_3/BaseKindDiscriminatorConverter.cs
similarity index 97%
rename from src/A2A/BaseKindDiscriminatorConverter.cs
rename to src/A2A.V0_3/BaseKindDiscriminatorConverter.cs
index 24ede8bf..d76084fe 100644
--- a/src/A2A/BaseKindDiscriminatorConverter.cs
+++ b/src/A2A.V0_3/BaseKindDiscriminatorConverter.cs
@@ -1,93 +1,93 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-
-namespace A2A;
-
-internal abstract class BaseKindDiscriminatorConverter : JsonConverter
- where TBase : class
-{
- internal const string DiscriminatorPropertyName = "kind";
-
- ///
- /// Gets the mapping from kind string values to their corresponding concrete types.
- ///
- protected abstract IReadOnlyDictionary KindToTypeMapping { get; }
-
- ///
- /// Gets the entity name used in error messages (e.g., "part", "file content", "event").
- ///
- protected abstract string DisplayName { get; }
-
- ///
- /// Reads an instance of from JSON using a kind discriminator.
- ///
- /// The to read from.
- /// The type to convert (ignored).
- /// Serialization options used to obtain type metadata.
- /// The deserialized instance of .
- public override TBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
- {
- using var document = JsonDocument.ParseValue(ref reader);
- var root = document.RootElement;
-
- if (!root.TryGetProperty(DiscriminatorPropertyName, out var kindProp))
- {
- throw new A2AException($"Missing required '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest);
- }
-
- if (kindProp.ValueKind is not JsonValueKind.String)
- {
- throw new A2AException($"Invalid '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}: '{(kindProp.ValueKind is JsonValueKind.Null ? "null" : kindProp)}'.", A2AErrorCode.InvalidRequest);
- }
-
- var kindValue = kindProp.GetString();
- if (string.IsNullOrEmpty(kindValue))
- {
- throw new A2AException($"Missing '{DiscriminatorPropertyName}' discriminator value for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest);
- }
-
- var kindToTypeMapping = KindToTypeMapping;
- if (!kindToTypeMapping.TryGetValue(kindValue!, out var targetType))
- {
- throw new A2AException($"Unknown {DisplayName} kind: '{kindValue}'", A2AErrorCode.InvalidRequest);
- }
-
- TBase? obj = null;
- Exception? deserializationException = null;
- try
- {
- var typeInfo = options.GetTypeInfo(targetType);
- obj = (TBase?)root.Deserialize(typeInfo);
- }
- catch (Exception e)
- {
- deserializationException = e;
- }
-
- if (deserializationException is not null || obj is null)
- {
- throw new A2AException($"Failed to deserialize '{kindValue}' {DisplayName}", deserializationException, A2AErrorCode.InvalidRequest);
- }
-
- return obj;
- }
-
- ///
- /// Writes the provided value to JSON.
- ///
- /// The to write to.
- /// The value to write.
- /// Serialization options used to obtain type metadata.
- public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options)
- {
- var element = JsonSerializer.SerializeToElement(value, options.GetTypeInfo(value.GetType()));
- writer.WriteStartObject();
-
- foreach (var prop in element.EnumerateObject())
- {
- prop.WriteTo(writer);
- }
-
- writer.WriteEndObject();
- }
-}
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace A2A.V0_3;
+
+internal abstract class BaseKindDiscriminatorConverter : JsonConverter
+ where TBase : class
+{
+ internal const string DiscriminatorPropertyName = "kind";
+
+ ///
+ /// Gets the mapping from kind string values to their corresponding concrete types.
+ ///
+ protected abstract IReadOnlyDictionary KindToTypeMapping { get; }
+
+ ///
+ /// Gets the entity name used in error messages (e.g., "part", "file content", "event").
+ ///
+ protected abstract string DisplayName { get; }
+
+ ///
+ /// Reads an instance of from JSON using a kind discriminator.
+ ///
+ /// The to read from.
+ /// The type to convert (ignored).
+ /// Serialization options used to obtain type metadata.
+ /// The deserialized instance of .
+ public override TBase Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ using var document = JsonDocument.ParseValue(ref reader);
+ var root = document.RootElement;
+
+ if (!root.TryGetProperty(DiscriminatorPropertyName, out var kindProp))
+ {
+ throw new A2AException($"Missing required '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest);
+ }
+
+ if (kindProp.ValueKind is not JsonValueKind.String)
+ {
+ throw new A2AException($"Invalid '{DiscriminatorPropertyName}' discriminator for {typeof(TBase).Name}: '{(kindProp.ValueKind is JsonValueKind.Null ? "null" : kindProp)}'.", A2AErrorCode.InvalidRequest);
+ }
+
+ var kindValue = kindProp.GetString();
+ if (string.IsNullOrEmpty(kindValue))
+ {
+ throw new A2AException($"Missing '{DiscriminatorPropertyName}' discriminator value for {typeof(TBase).Name}.", A2AErrorCode.InvalidRequest);
+ }
+
+ var kindToTypeMapping = KindToTypeMapping;
+ if (!kindToTypeMapping.TryGetValue(kindValue!, out var targetType))
+ {
+ throw new A2AException($"Unknown {DisplayName} kind: '{kindValue}'", A2AErrorCode.InvalidRequest);
+ }
+
+ TBase? obj = null;
+ Exception? deserializationException = null;
+ try
+ {
+ var typeInfo = options.GetTypeInfo(targetType);
+ obj = (TBase?)root.Deserialize(typeInfo);
+ }
+ catch (Exception e)
+ {
+ deserializationException = e;
+ }
+
+ if (deserializationException is not null || obj is null)
+ {
+ throw new A2AException($"Failed to deserialize '{kindValue}' {DisplayName}", deserializationException, A2AErrorCode.InvalidRequest);
+ }
+
+ return obj;
+ }
+
+ ///
+ /// Writes the provided value to JSON.
+ ///
+ /// The to write to.
+ /// The value to write.
+ /// Serialization options used to obtain type metadata.
+ public override void Write(Utf8JsonWriter writer, TBase value, JsonSerializerOptions options)
+ {
+ var element = JsonSerializer.SerializeToElement(value, options.GetTypeInfo(value.GetType()));
+ writer.WriteStartObject();
+
+ foreach (var prop in element.EnumerateObject())
+ {
+ prop.WriteTo(writer);
+ }
+
+ writer.WriteEndObject();
+ }
+}
diff --git a/src/A2A.V0_3/Client/A2ACardResolver.cs b/src/A2A.V0_3/Client/A2ACardResolver.cs
new file mode 100644
index 00000000..1a80e33f
--- /dev/null
+++ b/src/A2A.V0_3/Client/A2ACardResolver.cs
@@ -0,0 +1,85 @@
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using System.Net;
+using System.Text.Json;
+
+namespace A2A.V0_3;
+
+///
+/// Resolves Agent Card information from an A2A-compatible endpoint.
+///
+public sealed class A2ACardResolver
+{
+ private readonly HttpClient _httpClient;
+ private readonly Uri _agentCardPath;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The base url of the agent's hosting service.
+ /// Optional HTTP client (if not provided, a shared one will be used).
+ /// Path to the agent card (defaults to "/.well-known/agent-card.json").
+ /// Optional logger.
+ public A2ACardResolver(
+ Uri baseUrl,
+ HttpClient? httpClient = null,
+ string agentCardPath = "/.well-known/agent-card.json",
+ ILogger? logger = null)
+ {
+ if (baseUrl is null)
+ {
+ throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null.");
+ }
+
+ if (string.IsNullOrEmpty(agentCardPath))
+ {
+ throw new ArgumentNullException(nameof(agentCardPath), "Agent card path cannot be null or empty.");
+ }
+
+ _agentCardPath = new Uri(baseUrl, agentCardPath.TrimStart('/'));
+
+ _httpClient = httpClient ?? A2AClient.s_sharedClient;
+
+ _logger = logger ?? NullLogger.Instance;
+ }
+
+ ///
+ /// Gets the agent card asynchronously.
+ ///
+ /// Optional cancellation token.
+ /// The agent card.
+ public async Task GetAgentCardAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_logger.IsEnabled(LogLevel.Information))
+ {
+ _logger.FetchingAgentCardFromUrl(_agentCardPath);
+ }
+
+ try
+ {
+ using var response = await _httpClient.GetAsync(_agentCardPath, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+
+ response.EnsureSuccessStatusCode();
+
+ using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+
+ return await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.AgentCard, cancellationToken).ConfigureAwait(false) ??
+ throw new A2AException("Failed to parse agent card JSON.");
+ }
+ catch (JsonException ex)
+ {
+ _logger.FailedToParseAgentCardJson(ex);
+ throw new A2AException($"Failed to parse JSON: {ex.Message}");
+ }
+ catch (HttpRequestException ex)
+ {
+ HttpStatusCode statusCode = ex.StatusCode ?? HttpStatusCode.InternalServerError;
+
+ _logger.HttpRequestFailedWithStatusCode(ex, statusCode);
+ throw new A2AException("HTTP request failed", ex);
+ }
+ }
+}
diff --git a/src/A2A.V0_3/Client/A2AClient.cs b/src/A2A.V0_3/Client/A2AClient.cs
new file mode 100644
index 00000000..0771043b
--- /dev/null
+++ b/src/A2A.V0_3/Client/A2AClient.cs
@@ -0,0 +1,198 @@
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+namespace A2A.V0_3;
+
+///
+/// Implementation of A2A client for communicating with agents.
+///
+public sealed class A2AClient : IA2AClient
+{
+ internal static readonly HttpClient s_sharedClient = new();
+ private readonly HttpClient _httpClient;
+ private readonly Uri _baseUri;
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The base url of the agent's hosting service.
+ /// The HTTP client to use for requests.
+ public A2AClient(Uri baseUrl, HttpClient? httpClient = null)
+ {
+ if (baseUrl is null)
+ {
+ throw new ArgumentNullException(nameof(baseUrl), "Base URL cannot be null.");
+ }
+
+ _baseUri = baseUrl;
+
+ _httpClient = httpClient ?? s_sharedClient;
+ }
+
+ ///
+ public Task SendMessageAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) =>
+ SendRpcRequestAsync(
+ taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)),
+ A2AMethods.MessageSend,
+ A2AJsonUtilities.JsonContext.Default.MessageSendParams,
+ A2AJsonUtilities.JsonContext.Default.A2AResponse,
+ cancellationToken);
+
+ ///
+ public Task GetTaskAsync(string taskId, CancellationToken cancellationToken = default) =>
+ SendRpcRequestAsync(
+ new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId },
+ A2AMethods.TaskGet,
+ A2AJsonUtilities.JsonContext.Default.TaskIdParams,
+ A2AJsonUtilities.JsonContext.Default.AgentTask,
+ cancellationToken);
+
+ ///
+ public Task CancelTaskAsync(TaskIdParams taskIdParams, CancellationToken cancellationToken = default) =>
+ SendRpcRequestAsync(
+ taskIdParams ?? throw new ArgumentNullException(nameof(taskIdParams)),
+ A2AMethods.TaskCancel,
+ A2AJsonUtilities.JsonContext.Default.TaskIdParams,
+ A2AJsonUtilities.JsonContext.Default.AgentTask,
+ cancellationToken);
+
+ ///
+ public Task SetPushNotificationAsync(TaskPushNotificationConfig pushNotificationConfig, CancellationToken cancellationToken = default) =>
+ SendRpcRequestAsync(
+ pushNotificationConfig ?? throw new ArgumentNullException(nameof(pushNotificationConfig)),
+ A2AMethods.TaskPushNotificationConfigSet,
+ A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig,
+ A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig,
+ cancellationToken);
+
+ ///
+ public Task GetPushNotificationAsync(GetTaskPushNotificationConfigParams notificationConfigParams, CancellationToken cancellationToken = default) =>
+ SendRpcRequestAsync(
+ notificationConfigParams ?? throw new ArgumentNullException(nameof(notificationConfigParams)),
+ A2AMethods.TaskPushNotificationConfigGet,
+ A2AJsonUtilities.JsonContext.Default.GetTaskPushNotificationConfigParams,
+ A2AJsonUtilities.JsonContext.Default.TaskPushNotificationConfig,
+ cancellationToken);
+
+ ///
+ public IAsyncEnumerable> SendMessageStreamingAsync(MessageSendParams taskSendParams, CancellationToken cancellationToken = default) =>
+ SendRpcSseRequestAsync(
+ taskSendParams ?? throw new ArgumentNullException(nameof(taskSendParams)),
+ A2AMethods.MessageStream,
+ A2AJsonUtilities.JsonContext.Default.MessageSendParams,
+ A2AJsonUtilities.JsonContext.Default.A2AEvent,
+ cancellationToken);
+
+ ///
+ public IAsyncEnumerable> SubscribeToTaskAsync(string taskId, CancellationToken cancellationToken = default) =>
+ SendRpcSseRequestAsync(
+ new() { Id = string.IsNullOrEmpty(taskId) ? throw new ArgumentNullException(nameof(taskId)) : taskId },
+ A2AMethods.TaskSubscribe,
+ A2AJsonUtilities.JsonContext.Default.TaskIdParams,
+ A2AJsonUtilities.JsonContext.Default.A2AEvent,
+ cancellationToken);
+
+ private async Task SendRpcRequestAsync(
+ TInput jsonRpcParams,
+ string method,
+ JsonTypeInfo inputTypeInfo,
+ JsonTypeInfo outputTypeInfo,
+ CancellationToken cancellationToken) where TOutput : class
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ using var responseStream = await SendAndReadResponseStreamAsync(
+ jsonRpcParams,
+ method,
+ inputTypeInfo,
+ "application/json",
+ cancellationToken).ConfigureAwait(false);
+
+ var responseObject = await JsonSerializer.DeserializeAsync(responseStream, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse, cancellationToken).ConfigureAwait(false);
+
+ if (responseObject?.Error is { } error)
+ {
+ throw new A2AException(error.Message, (A2AErrorCode)error.Code);
+ }
+
+ return responseObject?.Result?.Deserialize(outputTypeInfo) ??
+ throw new InvalidOperationException("Response does not contain a result.");
+ }
+
+ private async IAsyncEnumerable> SendRpcSseRequestAsync(
+ TInput jsonRpcParams,
+ string method,
+ JsonTypeInfo inputTypeInfo,
+ JsonTypeInfo outputTypeInfo,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ using var responseStream = await SendAndReadResponseStreamAsync(
+ jsonRpcParams,
+ method,
+ inputTypeInfo,
+ "text/event-stream",
+ cancellationToken).ConfigureAwait(false);
+
+ var sseParser = SseParser.Create(responseStream, (_, data) =>
+ {
+ var reader = new Utf8JsonReader(data);
+
+ var responseObject = JsonSerializer.Deserialize(ref reader, A2AJsonUtilities.JsonContext.Default.JsonRpcResponse);
+
+ if (responseObject?.Error is { } error)
+ {
+ throw new A2AException(error.Message, (A2AErrorCode)error.Code);
+ }
+
+ 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.");
+ });
+
+ await foreach (var item in sseParser.EnumerateAsync(cancellationToken))
+ {
+ yield return item;
+ }
+ }
+
+ private async ValueTask SendAndReadResponseStreamAsync(
+ TInput jsonRpcParams,
+ string method,
+ JsonTypeInfo inputTypeInfo,
+ string expectedContentType,
+ CancellationToken cancellationToken)
+ {
+ var response = await _httpClient.SendAsync(new(HttpMethod.Post, _baseUri)
+ {
+ Content = new JsonRpcContent(new JsonRpcRequest()
+ {
+ Id = Guid.NewGuid().ToString(),
+ Method = method,
+ Params = JsonSerializer.SerializeToElement(jsonRpcParams, inputTypeInfo),
+ })
+ }, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ response.EnsureSuccessStatusCode();
+
+ if (response.Content.Headers.ContentType?.MediaType != expectedContentType)
+ {
+ throw new InvalidOperationException($"Invalid content type. Expected '{expectedContentType}' but got '{response.Content.Headers.ContentType?.MediaType}'.");
+ }
+
+ return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ response.Dispose();
+ throw;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/A2A.V0_3/Client/A2AClientExtensions.cs b/src/A2A.V0_3/Client/A2AClientExtensions.cs
new file mode 100644
index 00000000..934c080c
--- /dev/null
+++ b/src/A2A.V0_3/Client/A2AClientExtensions.cs
@@ -0,0 +1,132 @@
+using System.Net.ServerSentEvents;
+using System.Text.Json;
+
+namespace A2A.V0_3;
+
+///
+/// Extension methods for the class making its API more
+/// convenient for certain use-cases.
+///
+public static class A2AClientExtensions
+{
+ ///
+ public static Task SendMessageAsync(
+ this A2AClient client,
+ AgentMessage message,
+ MessageSendConfiguration? configuration = null,
+ Dictionary? metadata = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (client is null)
+ {
+ throw new ArgumentNullException(nameof(client));
+ }
+
+ return client.SendMessageAsync(
+ new MessageSendParams
+ {
+ Message = message,
+ Configuration = configuration,
+ Metadata = metadata
+ },
+ cancellationToken);
+ }
+
+ ///
+ public static Task CancelTaskAsync(
+ this A2AClient client,
+ string taskId,
+ Dictionary? metadata = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (client is null)
+ {
+ throw new ArgumentNullException(nameof(client));
+ }
+
+ return client.CancelTaskAsync(
+ new TaskIdParams
+ {
+ Id = taskId,
+ Metadata = metadata
+ },
+ cancellationToken);
+ }
+
+ ///
+ public static Task SetPushNotificationAsync(
+ this A2AClient client,
+ string taskId,
+ string url,
+ string? configId = null,
+ string? token = null,
+ PushNotificationAuthenticationInfo? authentication = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (client is null)
+ {
+ throw new ArgumentNullException(nameof(client));
+ }
+
+ return client.SetPushNotificationAsync(
+ new TaskPushNotificationConfig
+ {
+ TaskId = taskId,
+ PushNotificationConfig = new PushNotificationConfig
+ {
+ Id = configId,
+ Url = url,
+ Token = token,
+ Authentication = authentication
+ }
+ },
+ cancellationToken);
+ }
+
+ ///
+ public static Task GetPushNotificationAsync(
+ this A2AClient client,
+ string taskId,
+ string configId,
+ Dictionary? metadata = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (client is null)
+ {
+ throw new ArgumentNullException(nameof(client));
+ }
+
+ return client.GetPushNotificationAsync(
+ new GetTaskPushNotificationConfigParams
+ {
+ Id = taskId,
+ PushNotificationConfigId = configId,
+ Metadata = metadata
+ },
+ cancellationToken);
+ }
+
+ ///
+ public static IAsyncEnumerable> SendMessageStreamingAsync(
+ this A2AClient client,
+ AgentMessage message,
+ MessageSendConfiguration? configuration = null,
+ Dictionary