Skip to content

Refactor A2A client API and migrate to AIAgent integration#1345

Open
geffzhang wants to merge 3 commits intomasterfrom
a2aupgrade
Open

Refactor A2A client API and migrate to AIAgent integration#1345
geffzhang wants to merge 3 commits intomasterfrom
a2aupgrade

Conversation

@geffzhang
Copy link
Copy Markdown
Collaborator

This pull request introduces significant improvements to the A2A integration, focusing on updating dependencies, enhancing error handling, modernizing the A2A service implementation, and cleaning up legacy code. The changes improve reliability, maintainability, and compatibility with the latest A2A protocol and Microsoft Agents AI libraries.

A2A Integration and Service Improvements:

  • Upgraded A2A-related NuGet packages and added the Microsoft.Agents.AI.A2A dependency in both Directory.Packages.props and BotSharp.Core.A2A.csproj to support the latest protocol and features. [1] [2]
  • Refactored A2AService to use high-level A2A v1 APIs, including caching, session management, and improved message sending and streaming methods. Removed or replaced legacy methods for task/event handling and push notifications. [1] [2] [3]
  • Updated the streaming interface in IA2AService and implementation to use AgentResponseUpdate instead of legacy SSE types, aligning with the new A2A protocol. [1] [2]

Error Handling and Logging Enhancements:

  • Improved error handling in A2AAgentHook by catching exceptions during agent card resolution and logging warnings, ensuring graceful fallback to configured metadata. [1] [2] [3]
  • Injected and utilized ILogger in both A2AAgentHook and A2AService for better observability and diagnostics. [1] [2]

Behavioral and Configuration Updates:

  • Ensured that message completion is stopped after delegating to A2A by setting StopCompletion = true in A2ADelegationFn.cs.
  • Updated agent test data to reflect new agent naming, description, and LLM configuration for better alignment with the orchestration backend.
  • Added .vscode/settings.json to suppress the Aspire settings file prompt on startup, improving developer experience.

geffzhang and others added 2 commits April 2, 2026 19:24
Introduce a CreateClientAsync client cache and migrate A2AService to the new A2A SDK surface (new message/request/response types, streaming StreamResponse, task subscribe/send request objects). Update IA2AService signatures accordingly (streaming callbacks, push notification now requires taskId). Update Directory.Packages.props to A2A 1.0.0-preview. Update appsettings.json to use gpt-4.1 model and switch test agent config to OpenClaw with the local A2A endpoint; mirror agent name/description change in test data.
Migrate A2A implementation to the newer Microsoft.Agents.AI AIAgent-based APIs: add Microsoft.Agents.AI package and project reference, replace low-level A2AClient usage with AIAgent and A2ACard resolver, and implement continuation token/session caching for runs. Update streaming and send methods signatures to use AgentResponseUpdate/RunStreaming, update IA2AService accordingly, and add logging and error handling when resolving remote agent cards. Minor behavioral tweaks: set StopCompletion in delegation function, adjust function parameter construction in the agent hook. Also bump A2A package version, update test agent LLM provider/model to azure-openai gpt-4.1, and add a .vscode settings file.
@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Migrate A2A integration to AIAgent API with session caching and improved error handling

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Migrate A2A service to AIAgent-based APIs with session caching
• Replace low-level A2AClient with high-level AIAgent and A2ACardResolver
• Update streaming interface to use AgentResponseUpdate instead of SSE types
• Add error handling and logging for agent card resolution
• Remove legacy task/event APIs and push notification methods
• Set StopCompletion flag after A2A delegation
Diagram
flowchart LR
  A["Low-level A2AClient"] -->|"Migrate to"| B["AIAgent + A2ACardResolver"]
  B -->|"Cache"| C["AIAgent Cache"]
  B -->|"Session Management"| D["Continuation Token Cache"]
  E["SSE-based Streaming"] -->|"Replace with"| F["AgentResponseUpdate Streaming"]
  G["Legacy Task APIs"] -->|"Remove"| H["Simplified Interface"]
  I["A2AAgentHook"] -->|"Add"| J["Error Handling & Logging"]
Loading

Grey Divider

File Changes

1. src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs ✨ Enhancement +88/-99

Refactor to AIAgent API with caching and streaming

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs


2. src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs ✨ Enhancement +3/-11

Update interface signatures for new streaming types

src/Infrastructure/BotSharp.Core.A2A/Services/IA2AService.cs


3. src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs Error handling +36/-23

Add error handling, logging, and fix indentation

src/Infrastructure/BotSharp.Core.A2A/Hooks/A2AAgentHook.cs


View more (6)
4. src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs ✨ Enhancement +1/-0

Set StopCompletion flag after delegation

src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs


5. Directory.Packages.props Dependencies +2/-1

Upgrade A2A and add Microsoft.Agents.AI.A2A

Directory.Packages.props


6. src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj Dependencies +1/-0

Add Microsoft.Agents.AI.A2A package reference

src/Infrastructure/BotSharp.Core.A2A/BotSharp.Core.A2A.csproj


7. src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs Formatting +1/-1

Minor whitespace adjustment

src/Infrastructure/BotSharp.Core.A2A/Settings/A2ASettings.cs


8. tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json ⚙️ Configuration changes +4/-4

Update test agent to OpenClaw with gpt-4.1

tests/BotSharp.Plugin.PizzaBot/data/agents/cdd9023f-a371-407a-43bf-f36ddccce340/agent.json


9. .vscode/settings.json ⚙️ Configuration changes +3/-0

Add VSCode settings for Aspire

.vscode/settings.json


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 5, 2026

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. agentEndpoint missing null guard ✓ Resolved 📘 Rule violation ☼ Reliability
Description
Integration-boundary methods build new Uri(agentEndpoint) without validating for null/empty, which
can throw and fail requests unexpectedly. This violates the null/empty guard requirement for
boundary inputs.
Code

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[R39-53]

   public async Task<AgentCard> GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default)
   {
       var resolver = new A2ACardResolver(new Uri(agentEndpoint));
-        return await resolver.GetAgentCardAsync();
+        return await resolver.GetAgentCardAsync(cancellationToken);
   }

-    public async Task<string> SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken)
+    private async Task<AIAgent> CreateAIAgentAsync(string agentEndpoint, CancellationToken cancellationToken = default)
   {
+        if (_aiAgentCache.TryGetValue(agentEndpoint, out var cachedAgent))
+        {
+            return cachedAgent;
+        }
+
+        var resolver = new A2ACardResolver(new Uri(agentEndpoint));
+        var aiAgent = await resolver.GetAIAgentAsync();
Evidence
PR Compliance ID 2 requires null/empty validation at API/integration boundaries; the updated code
constructs Uri directly from agentEndpoint in GetCapabilitiesAsync/CreateAIAgentAsync
without any string.IsNullOrWhiteSpace guard.

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[39-53]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`agentEndpoint`/`endPoint` are used to create `new Uri(...)` without null/empty validation, which can throw at runtime.
## Issue Context
These methods are integration boundaries (remote A2A endpoints). Per compliance, they should guard inputs and return a safe fallback (or explicitly fail fast with a clear error) rather than crashing via unhandled `Uri` construction errors.
## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[39-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. parts not defensively copied ✓ Resolved 📘 Rule violation ≡ Correctness
Description
SendMessageStreamingAsync assigns the caller-provided List directly to Message.Parts, allowing
shared mutable state across layers and potential unexpected mutation. This violates the
defensive-copy requirement for caller-provided collections.
Code

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[R124-131]

+    public async Task SendMessageStreamingAsync(string endPoint, List<Part> parts, Func<AgentResponseUpdate, Task>? onStreamingEventReceived, CancellationToken cancellationToken = default)
   {
-        A2ACardResolver cardResolver = new(new Uri(endPoint));
-        AgentCard agentCard = await cardResolver.GetAgentCardAsync();
-        A2AClient client = new A2AClient(new Uri(agentCard.Url));
-
-        AgentMessage userMessage = new()
+        var userMessage = new Message
       {
-            Role = MessageRole.User,
+            MessageId = Guid.NewGuid().ToString("N"),
+            Role = Role.User,
           Parts = parts
       };
Evidence
PR Compliance ID 1 requires defensive copies when storing/exposing caller-provided collections; the
new streaming path stores the passed-in parts list reference directly in the Message object
(Parts = parts).

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[124-131]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SendMessageStreamingAsync` stores the caller-provided `List<Part>` by reference (`Parts = parts`), which can leak mutable state across components.
## Issue Context
Even if `parts` is not mutated locally, downstream code/libraries may mutate it; copying prevents cross-request/shared-state bugs.
## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[124-131]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Token cache not persisted ✓ Resolved 🐞 Bug ≡ Correctness
Description
A2AService stores ResponseContinuationToken only in a private in-memory dictionary, but messages are
processed through per-request HTTP endpoints and IA2AService is registered as scoped, so the
continuation token will be lost between user messages and subsequent calls won’t include session
continuation.
Code

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[R23-110]

+    private readonly Dictionary<string, AIAgent> _aiAgentCache = new();
+#pragma warning disable MEAI001
+    private readonly Dictionary<string, ResponseContinuationToken> _continuationTokenCache = new();
+#pragma warning restore MEAI001

-    private readonly Dictionary<string, A2AClient> _clientCache = new Dictionary<string, A2AClient>();
+    // LEGACY: Used for task APIs and the StreamResponse compatibility overload.
+    private readonly Dictionary<string, A2AClient> _clientCache = new();

-    public A2AService(IHttpClientFactory httpClientFactory, IServiceProvider services, ILogger<A2AService> logger)
+    public A2AService(IHttpClientFactory httpClientFactory, IServiceProvider services, ILogger<A2AService> logger, A2ASettings settings)
   {
       _httpClientFactory = httpClientFactory;
       _services = services;
       _logger = logger;
-    }
+        _settings = settings;
+    } 

   public async Task<AgentCard> GetCapabilitiesAsync(string agentEndpoint, CancellationToken cancellationToken = default)
   {
       var resolver = new A2ACardResolver(new Uri(agentEndpoint));
-        return await resolver.GetAgentCardAsync();
+        return await resolver.GetAgentCardAsync(cancellationToken);
   }

-    public async Task<string> SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken)
+    private async Task<AIAgent> CreateAIAgentAsync(string agentEndpoint, CancellationToken cancellationToken = default)
   {
+        if (_aiAgentCache.TryGetValue(agentEndpoint, out var cachedAgent))
+        {
+            return cachedAgent;
+        }
+
+        var resolver = new A2ACardResolver(new Uri(agentEndpoint));
+        var aiAgent = await resolver.GetAIAgentAsync();
+        _aiAgentCache[agentEndpoint] = aiAgent;
+        return aiAgent;
+    }
+
+    private static string BuildSessionCacheKey(string agentEndpoint, string contextId)
+        => $"{agentEndpoint}::{contextId}";

-        if (!_clientCache.TryGetValue(agentEndpoint, out var client))
+#pragma warning disable MEAI001
+    private AgentRunOptions? GetRunOptions(string agentEndpoint, string contextId)
+    {
+        if (string.IsNullOrWhiteSpace(contextId))
       {
-            HttpClient httpclient = _httpClientFactory.CreateClient();
+            return null;
+        }

-            client = new A2AClient(new Uri(agentEndpoint), httpclient);
-            _clientCache[agentEndpoint] = client;
+        var cacheKey = BuildSessionCacheKey(agentEndpoint, contextId);
+        if (!_continuationTokenCache.TryGetValue(cacheKey, out var continuationToken))
+        {
+            return null;
       }

-        var messagePayload = new AgentMessage
+        return new AgentRunOptions
       {
-            Role = MessageRole.User, 
-            ContextId = contextId,           
-            Parts = new List<Part>
-            {
-                new TextPart { Text = text }
-            }
+            ContinuationToken = continuationToken
       };
+    }

-        var sendParams = new MessageSendParams
+    private void UpdateContinuationToken(string agentEndpoint, string contextId, ResponseContinuationToken? continuationToken)
+    {
+        if (string.IsNullOrWhiteSpace(contextId) || continuationToken == null)
       {
-            Message = messagePayload
-        };
+            return;
+        }
+
+        var cacheKey = BuildSessionCacheKey(agentEndpoint, contextId);
+        _continuationTokenCache[cacheKey] = continuationToken;
+    }
+#pragma warning restore MEAI001

+    // HIGH-LEVEL: Preferred A2A v1 API for message sending
+    public async Task<string> SendMessageAsync(string agentEndpoint, string text, string contextId, CancellationToken cancellationToken)
+    {
       try
       {
-            _logger.LogInformation($"Sending A2A message to {agentEndpoint}. ContextId: {contextId}");          
-            var responseBase = await client.SendMessageAsync(sendParams, cancellationToken);
-
-            if (responseBase is AgentMessage responseMsg)
-            {                
-                if (responseMsg.Parts != null && responseMsg.Parts.Any())
-                {
-                    var textPart = responseMsg.Parts.First() as TextPart;
-                    return textPart?.Text ?? string.Empty;
-                }
-            }
-            else if( responseBase is AgentTask atask)
-            {
-                return $"Task created with ID: {atask.Id}, Status: {atask.Status}";
-            }
-            else
-            {
-                return "Unexpected task type.";
-            }
-
-            return string.Empty;
+            var agent = await CreateAIAgentAsync(agentEndpoint, cancellationToken);
+            var runOptions = GetRunOptions(agentEndpoint, contextId);
+            _logger.LogInformation("Sending A2A message via AIAgent to {AgentEndpoint}. ContextId: {ContextId}", agentEndpoint, contextId);
+            var response = await agent.RunAsync(
+                message: text ?? string.Empty,
+                options: runOptions,
+                cancellationToken: cancellationToken);
+
+#pragma warning disable MEAI001
+            UpdateContinuationToken(agentEndpoint, contextId, response.ContinuationToken);
+#pragma warning restore MEAI001
+            return response.Text ?? string.Empty;
       }
Evidence
A2AService caches continuation tokens in a private Dictionary keyed by (endpoint, contextId) and
only reuses them if present. However, the A2A plugin registers IA2AService as scoped, and the
primary chat path is an HTTP POST endpoint invoked per message; scoped services are recreated per
request, so the token cache will be empty on the next message even when the same conversationId is
used as contextId.

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[23-110]
src/Infrastructure/BotSharp.Core.A2A/A2APlugin.cs[24-35]
src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs[45-58]
src/Infrastructure/BotSharp.OpenAPI/Controllers/Conversation/ConversationController.cs[378-418]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`A2AService` stores `ResponseContinuationToken` in an in-memory dictionary. Since `IA2AService` is registered as **scoped** and chat messages are processed by per-request HTTP endpoints, a new `A2AService` instance is created for each user message and the token cache is lost. This prevents A2A multi-turn session continuation.
### Issue Context
- `A2ADelegationFn` passes `conversationId` as `contextId`, implying multi-turn continuity is expected.
- `A2AService` only continues a session when it can retrieve a previously cached continuation token.
### Fix Focus Areas
- src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[23-110]
- src/Infrastructure/BotSharp.Core.A2A/A2APlugin.cs[24-35]
- src/Infrastructure/BotSharp.Core.A2A/Functions/A2ADelegationFn.cs[45-58]
### Implementation direction (choose one)
1) **Persist tokens in conversation state** (recommended): serialize/store the continuation token in `IConversationStateService` (or another durable per-conversation store) keyed by `(agentEndpoint, conversationId)` and restore it when building `AgentRunOptions`.
2) **Make session cache truly long-lived**: change `IA2AService` lifetime to singleton and make the caches thread-safe + bounded (e.g., `ConcurrentDictionary` + eviction/TTL) to avoid unbounded growth.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. CreateAIAgentAsync ignores cancellation 📘 Rule violation ☼ Reliability
Description
CreateAIAgentAsync accepts a CancellationToken but does not pass or check it during external I/O
(agent resolution), so cancellation may be delayed/ignored. This reduces reliability and violates
the cancellation/async observability requirement.
Code

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[R45-55]

+    private async Task<AIAgent> CreateAIAgentAsync(string agentEndpoint, CancellationToken cancellationToken = default)
   {
+        if (_aiAgentCache.TryGetValue(agentEndpoint, out var cachedAgent))
+        {
+            return cachedAgent;
+        }
+
+        var resolver = new A2ACardResolver(new Uri(agentEndpoint));
+        var aiAgent = await resolver.GetAIAgentAsync();
+        _aiAgentCache[agentEndpoint] = aiAgent;
+        return aiAgent;
Evidence
PR Compliance ID 3 requires honoring cancellation in async/external I/O; the updated method takes
cancellationToken but calls resolver.GetAIAgentAsync() without passing or checking cancellation.

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[45-55]
Best Practice: Learned patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`CreateAIAgentAsync` takes `cancellationToken` but does not honor it during the external call to resolve the agent.
## Issue Context
If `GetAIAgentAsync` has a cancellation overload, pass the token. If it does not, add `cancellationToken.ThrowIfCancellationRequested()` before/after the call and avoid caching results when cancellation is requested.
## Fix Focus Areas
- src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[45-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Exception chain discarded 🐞 Bug ☼ Reliability
Description
SendMessageAsync catches HttpRequestException and throws a new generic Exception without preserving
the original exception as InnerException, which prevents callers from distinguishing network
failures and drops the exception chain.
Code

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[R111-112]

       catch (HttpRequestException ex)
       {
Evidence
The code constructs a new Exception using only the message, discarding the original exception
type/details for downstream handlers.

src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[111-115]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SendMessageAsync` replaces `HttpRequestException` with a new `Exception` without setting `InnerException`, losing the original exception type and chain.
### Issue Context
Callers may want to handle transient network failures differently from protocol/logic failures.
### Fix Focus Areas
- src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs[111-115]
### Fix
Throw a typed exception while preserving the original as inner, e.g.:
- `throw new Exception($"Remote agent unavailable: {ex.Message}", ex);`
Or define a dedicated exception type (e.g., `RemoteAgentUnavailableException`) and include `ex` as the inner exception.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs
Comment thread src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs
Comment thread src/Infrastructure/BotSharp.Core.A2A/Services/A2AService.cs
Store A2A ResponseContinuationToken in conversation state so continuation can survive service scopes. Adds SHA256-hashed state keys, Read/Persist helpers, and caches continuation tokens back into the in-memory cache. Also adds endpoint URI validation, safer JSON/argument handling in A2ADelegationFn, and null/empty safeguards when building user message parts. Logs and corrupted-token handling now remove invalid state and emit warnings. Includes unit tests (A2AServiceContinuationTokenTests) and updates the test project reference to include the A2A project.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant