119 KiB
A2A client Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let the owner's assistant call other A2A agents: an a2a tool (agents | send | status | cancel) over a plain agents.json in the workspace, with long tasks handed to watches that deliver results later as follow-up prompts. Off by default behind A2AClient__Enabled.
Architecture: A feature extension extensions/Volcanic.AgenticCore.Extensions.A2A (references only Abstractions + the official A2A 1.0.0-preview2 client package). A process-wide A2AWatchRegistry owns one HTTP handler, the watches (subscribe / poll / push) and pending.json persistence; it delivers through IA2AResultDelivery (default LiveSessionDelivery; Telegram: TelegramA2AResultDelivery). The Telegram host adds A2AClientExtension per chat session (never to A2A server sessions) when A2AClient:Enabled is on, restores watches at startup and, when the server is on too, routes the server's push receiver into the registry.
Tech Stack: .NET 10, A2A 1.0.0-preview2, xunit 2, Microsoft.AspNetCore.TestHost (tests run against the in-process Volcanic.AgenticCore.A2A.Server), Microsoft.Extensions.TimeProvider.Testing, the core FauxProvider.
Spec: docs/superpowers/specs/2026-10-07-a2a-client-design.md, including its "Revisions (2026-10-08)" section, which wins over older sections.
Facts the engineer needs
- Namespace gotcha (bit us in the server): our namespace
Volcanic.AgenticCore.Extensions.A2Acontains a segmentA2A, so a qualified SDK referenceA2A.Xresolves to our namespace. Writeglobal::A2A.Xwhen qualifying.Message,TaskStatus,Partmay collide withVolcanic.AgenticCore.Abstractions.Messages.*/System.Threading.Tasks.TaskStatus: alias them, e.g.using A2AMessage = global::A2A.Message;. - SDK client (verified by reflection on the installed DLL):
new A2ACardResolver(Uri baseUrl, HttpClient? httpClient = null, string agentCardPath = "/.well-known/agent-card.json", ILogger? logger = null).GetAgentCardAsync(ct)A2AClientFactory.Create(AgentCard card, HttpClient? http = null, A2AClientOptions? options = null)→IA2AClient(picks the binding fromcard.SupportedInterfaces).IA2AClient:SendMessageAsync(SendMessageRequest)→SendMessageResponse { Task | Message },GetTaskAsync(new GetTaskRequest { Id }),CancelTaskAsync(new CancelTaskRequest { Id }),SubscribeToTaskAsync(new SubscribeToTaskRequest { Id })→IAsyncEnumerable<StreamResponse>,CreateTaskPushNotificationConfigAsync(new CreateTaskPushNotificationConfigRequest { TaskId, Config = new PushNotificationConfig { Url, Token } }),DeleteTaskPushNotificationConfigAsync(new DeleteTaskPushNotificationConfigRequest { TaskId, Id }),GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest()).TaskProjection.Apply(AgentTask? current, StreamResponse ev)→AgentTask?(public static).TaskStateExtensions.IsTerminal(state); statesSubmitted, Working, Completed, Failed, Canceled, InputRequired, Rejected, AuthRequired.AgentCard.Capabilities { Streaming, PushNotifications, ExtendedAgentCard }(nullable bools),SecuritySchemes: Dictionary<string, SecurityScheme>withApiKeySecurityScheme { Name, Location }/HttpAuthSecurityScheme { Scheme }.Part.FromText / FromRaw(bytes, mediaType, filename),part.ContentCase(Text, Raw, Url, Data),Message.Metadata/SendMessageRequest.Metadata:Dictionary<string, JsonElement>?,SendMessageConfiguration { ReturnImmediately }.- Errors: JSON-RPC errors →
A2AException { ErrorCode }; HTTP failures (e.g. 401) →HttpRequestException { StatusCode }.
- Our server (in this repo) is the remote in tests:
src/Volcanic.AgenticCore.A2A.Server—AddA2AServer,MapA2AServer,WorkspaceCallerDirectory,A2AWorkspace,CallersFile,A2AHostOptions { OnPush, AllowPrivatePushUrls, MaxHops },HttpA2APushSender(HttpClient, TimeProvider, ILogger<HttpA2APushSender>)(internal; the server project grantsInternalsVisibleToonly to its own tests and Telegram tests — Task 1 adds the extension tests). Its card advertisesstreamingandpushNotifications,X-API-Keyauth, and it honoursreturnImmediately. A context id must match^[A-Za-z0-9_-]{1,100}$. - Core:
IExtension,IExtensionApi(RegisterTool,On<TEvent>(ExtensionHandler),Events.On(channel, Action<object?>),SendUserMessage(text, images, MessageDelivery.FollowUp)),SessionStartEvent,SessionShutdownEvent,Tool<TArgs>(throw → error result),ToolInvocation.Context.Cwd. Test hosts: copy the shape oftests/Volcanic.AgenticCore.Extensions.Tests/Mcp/Support/McpTestHost.cs.
File structure
extensions/Volcanic.AgenticCore.Extensions.A2A/
Volcanic.AgenticCore.Extensions.A2A.csproj
A2AClientSettings.cs settings (section A2AClient)
AgentsFile.cs <ws>/.volcanic/a2a/agents.json
RemoteAgents.cs RemoteAgentConnector + RemoteAgent (card, auth header, IA2AClient)
A2AResults.cs state names, text extraction, artifact saving, result texts
PendingFile.cs WatchRecord + <ws>/.volcanic/a2a/pending.json
A2AResultDelivery.cs IA2AResultDelivery + LiveSessionDelivery
A2AWatchRegistry.cs watches, push, restore, delivery, waiting helper
A2ATool.cs the a2a tool
A2AClientExtension.cs per-session wiring (tool, hops, session start/shutdown)
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/
Support/RemoteAgentServer.cs, Support/A2AClientHost.cs, Support/PushToRegistryHandler.cs
AgentsFileTests.cs, A2AResultsTests.cs, PendingFileTests.cs, RemoteAgentsTests.cs,
A2AToolTests.cs, A2AWatchTests.cs, A2AHopsTests.cs
hosts/Volcanic.AgenticCore.Telegram/
Chats/ChatFollowUps.cs (extracted), A2A/TelegramA2AResultDelivery.cs, A2A/A2AWatchHostedService.cs,
A2A/TelegramA2AClientSetup.cs; ChatRegistry.cs, ChatCommands.cs, Program.cs, TelegramJobDelivery.cs modified
Task 1: Project, settings, test wiring
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/Volcanic.AgenticCore.Extensions.A2A.csproj -
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AClientSettings.cs -
Modify:
VolcanicAgenticCore.slnx(add under/extensions/) -
Modify:
tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj -
Modify:
src/Volcanic.AgenticCore.A2A.Server/Volcanic.AgenticCore.A2A.Server.csproj(InternalsVisibleTo the extension tests) -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AClientSettingsTests.cs -
Step 1: Project file
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Description>A2A client extension: the a2a tool, remote agents in agents.json, watches for long tasks.</Description>
</PropertyGroup>
<ItemGroup>
<!-- Private="false": the host already ships Abstractions; an extension must not bring its own copy (docs/extensions.md). -->
<ProjectReference Include="../../src/Volcanic.AgenticCore.Abstractions/Volcanic.AgenticCore.Abstractions.csproj" Private="false" />
<PackageReference Include="A2A" Version="1.0.0-preview2" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />
<InternalsVisibleTo Include="Volcanic.AgenticCore.Telegram.Tests" />
</ItemGroup>
</Project>
- Step 2: Settings
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>Section <c>A2AClient</c> (env <c>A2AClient__…</c>). Named so it doesn't clash with the SDK's <c>A2AClientOptions</c>.</summary>
public sealed class A2AClientSettings
{
public const string SectionName = "A2AClient";
/// <summary>Off by default: no <c>a2a</c> tool, no watches. Independent of the server's <c>A2A:Enabled</c>.</summary>
public bool Enabled { get; set; }
/// <summary>How long <c>send</c> waits for a result before a watch takes over. 0 hands over at once.</summary>
public int WaitSeconds { get; set; } = 60;
public TimeSpan WatchTimeout { get; set; } = TimeSpan.FromDays(1);
public int CardTimeoutSeconds { get; set; } = 10;
public long MaxFileBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>
/// Our public base URL for push webhooks (<c>{PushBaseUrl}/a2a/push/{watchId}</c>). Set by the host from the server's
/// <c>A2A:PublicUrl</c> only while the server (and so the push receiver) is on; null = subscribe or poll.
/// </summary>
public string? PushBaseUrl { get; set; }
}
- Step 3: Test project and server internals
In tests/Volcanic.AgenticCore.Extensions.Tests/Volcanic.AgenticCore.Extensions.Tests.csproj add:
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
<ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.A2A/Volcanic.AgenticCore.Extensions.A2A.csproj" />
<ProjectReference Include="../../src/Volcanic.AgenticCore.A2A.Server/Volcanic.AgenticCore.A2A.Server.csproj" />
(FrameworkReference goes in its own <ItemGroup> or next to the package references; both are fine.) In the server csproj add <InternalsVisibleTo Include="Volcanic.AgenticCore.Extensions.Tests" />. Add the extension project to VolcanicAgenticCore.slnx under /extensions/.
- Step 4: Smoke test
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AClientSettingsTests.cs:
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public class A2AClientSettingsTests
{
[Fact]
public void Defaults_OffWithSpecValues()
{
var s = new A2AClientSettings();
Assert.False(s.Enabled);
Assert.Equal(60, s.WaitSeconds);
Assert.Equal(TimeSpan.FromDays(1), s.WatchTimeout);
Assert.Equal(10, s.CardTimeoutSeconds);
Assert.Equal(10 * 1024 * 1024, s.MaxFileBytes);
Assert.Null(s.PushBaseUrl);
Assert.Equal("A2AClient", A2AClientSettings.SectionName);
}
}
- Step 5: Build and test
Run: dotnet build && dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~A2AClientSettingsTests"
Expected: 0 warnings; 1 passed. (If NuGet reports NU1510 or a version conflict for packages that Microsoft.AspNetCore.App already provides — e.g. Microsoft.Extensions.DependencyInjection / Logging — remove those explicit references from the test csproj; warnings are not errors in test projects, but keep the output clean.)
- Step 6: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A tests/Volcanic.AgenticCore.Extensions.Tests VolcanicAgenticCore.slnx src/Volcanic.AgenticCore.A2A.Server/Volcanic.AgenticCore.A2A.Server.csproj
git commit -m "feat(a2a-client): scaffold the A2A client extension behind A2AClient__Enabled"
Task 2: agents.json
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs -
Step 1: Failing tests
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public sealed class AgentsFileTests : IDisposable
{
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-agents", Guid.NewGuid().ToString("N"));
private void Write(string json)
{
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(_ws))!);
File.WriteAllText(AgentsFile.PathFor(_ws), json);
}
[Fact]
public void Missing_NoAgentsNoError()
{
var r = AgentsFile.Read(_ws);
Assert.Empty(r.Agents);
Assert.Null(r.Error);
}
[Fact]
public void Agents_MalformedFile_ReportsFile()
{
Write("{ nope");
var r = AgentsFile.Read(_ws);
Assert.Empty(r.Agents);
Assert.Contains("agents.json", r.Error);
}
[Fact]
public void Read_ValidatesEntries_AndNeverEchoesKeys()
{
Write("""
[ { "name": "travel-bot", "url": "https://travel.example/", "key": "secret-1", "description": "Trips" },
{ "name": "Bad Name", "url": "https://x.example/", "key": "secret-2" },
{ "name": "no-url" },
{ "name": "ftp", "url": "ftp://x.example/" },
{ "name": "travel-bot", "url": "https://dup.example/" } ]
""");
var r = AgentsFile.Read(_ws);
var agent = Assert.Single(r.Agents);
Assert.Equal(new RemoteAgentEntry("travel-bot", "https://travel.example/", "secret-1", "Trips"), agent);
Assert.Equal(4, r.Problems.Count);
Assert.DoesNotContain(r.Problems, p => p.Contains("secret"));
}
[Fact]
public void Agents_FileEdited_AppliesToNextCall()
{
Write("""[ { "name": "a", "url": "https://a.example/" } ]""");
Assert.NotNull(AgentsFile.Find(_ws, "a"));
Write("[]");
Assert.Null(AgentsFile.Find(_ws, "a"));
}
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
}
- Step 2: Run to see it fail
Run: dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~AgentsFileTests"
Expected: build error (AgentsFile missing).
- Step 3: Implement
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Volcanic.AgenticCore.Extensions.A2A;
public sealed record RemoteAgentEntry(string Name, string Url, string? Key, string? Description);
/// <param name="Problems">Skipped entries, described without keys.</param>
public sealed record AgentsFileResult(IReadOnlyList<RemoteAgentEntry> Agents, IReadOnlyList<string> Problems, string? Error);
/// <summary>
/// <c><workspace>/.volcanic/a2a/agents.json</c>: remote agents the assistant may call. A plain file the agent and the
/// user edit; read on every call (no cache).
/// </summary>
public static partial class AgentsFile
{
[GeneratedRegex("^[a-z0-9-]{1,40}$", RegexOptions.CultureInvariant)]
private static partial Regex NamePattern();
public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "a2a", "agents.json");
public static RemoteAgentEntry? Find(string workspace, string name) =>
Read(workspace).Agents.FirstOrDefault(a => a.Name == name);
public static AgentsFileResult Read(string workspace)
{
var path = PathFor(workspace);
if (!File.Exists(path)) return new([], [], null);
JsonElement root;
try
{
using var doc = JsonDocument.Parse(File.ReadAllText(path));
root = doc.RootElement.Clone();
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return new([], [], $".volcanic/a2a/agents.json could not be read: {ex.GetType().Name}.");
}
if (root.ValueKind != JsonValueKind.Array)
return new([], [], ".volcanic/a2a/agents.json must be a JSON array of agents.");
var agents = new List<RemoteAgentEntry>();
var problems = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var index = 0;
foreach (var item in root.EnumerateArray())
{
index++;
if (item.ValueKind != JsonValueKind.Object) { problems.Add($"entry {index} is not an object"); continue; }
var name = Str(item, "name");
var url = Str(item, "url");
if (name is null || !NamePattern().IsMatch(name)) { problems.Add($"entry {index}: name must match [a-z0-9-]{{1,40}}"); continue; }
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
{ problems.Add($"{name}: url must be an absolute http(s) URL"); continue; }
if (!seen.Add(name)) { problems.Add($"{name}: duplicate name"); continue; }
agents.Add(new RemoteAgentEntry(name, url, Str(item, "key"), Str(item, "description")));
}
return new(agents, problems, null);
}
private static string? Str(JsonElement e, string property) =>
e.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s ? s : null;
}
-
Step 4: Run tests — Expected: 4 passed.
-
Step 5: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs
git commit -m "feat(a2a-client): read remote agents from agents.json on every call"
Task 3: Results — state names, text, artifacts
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs -
Step 1: Failing tests
using System.Text;
using A2A;
using Volcanic.AgenticCore.Extensions.A2A;
using A2AMessage = global::A2A.Message;
using A2ATaskStatus = global::A2A.TaskStatus;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public sealed class A2AResultsTests : IDisposable
{
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-results", Guid.NewGuid().ToString("N"));
private static AgentTask Task_(TaskState state, string? statusText = null, params Artifact[] artifacts) => new()
{
Id = "task-123456789", ContextId = "ctx-1",
Status = new A2ATaskStatus
{
State = state,
Message = statusText is null ? null : new A2AMessage { Role = Role.Agent, MessageId = "m", Parts = [Part.FromText(statusText)] },
},
Artifacts = [.. artifacts],
};
[Fact]
public void FinalText_PrefersResponseArtifact()
{
var task = Task_(TaskState.Completed, null,
new Artifact { ArtifactId = "other", Parts = [Part.FromText("side")] },
new Artifact { ArtifactId = "response", Parts = [Part.FromText("Booked "), Part.FromText("Friday")] });
Assert.Equal("Booked Friday", A2AResults.FinalText(task));
}
[Fact]
public void FinalText_FallsBackToTextArtifactsThenStatus()
{
Assert.Equal("side", A2AResults.FinalText(Task_(TaskState.Completed, null, new Artifact { ArtifactId = "x", Parts = [Part.FromText("side")] })));
Assert.Equal("why", A2AResults.FinalText(Task_(TaskState.Failed, "why")));
}
[Fact]
public void Send_FileArtifact_SavedToInbox()
{
var task = Task_(TaskState.Completed, null, new Artifact
{
ArtifactId = "f1", Name = "plan.pdf", Parts = [Part.FromRaw(Encoding.UTF8.GetBytes("pdf!"), "application/pdf", "../../plan.pdf")],
});
var files = A2AResults.SaveFiles(_ws, "travel-bot", task, 1024);
var file = Assert.Single(files);
Assert.Equal("inbox/a2a/travel-bot/plan.pdf", file);
Assert.Equal("pdf!", File.ReadAllText(Path.Combine(_ws, file)));
}
[Fact]
public void Describe_InputRequired_HasQuestionAndHint()
{
var text = A2AResults.Describe("travel-bot", Task_(TaskState.InputRequired, "Which date?"), []);
Assert.Contains("input-required", text);
Assert.Contains("Which date?", text);
Assert.Contains("action=send", text);
Assert.Contains("task-123456789", text);
Assert.Contains("ctx-1", text);
}
[Fact]
public void DeliveryText_HasHeader()
{
var text = A2AResults.DeliveryText("travel-bot", Task_(TaskState.Completed, null,
new Artifact { ArtifactId = "response", Parts = [Part.FromText("Done")] }), ["inbox/a2a/travel-bot/x.pdf"]);
Assert.StartsWith("[A2A result from \"travel-bot\", task task-123456789 (completed)]", text);
Assert.Contains("Done", text);
Assert.Contains("Files: inbox/a2a/travel-bot/x.pdf", text);
}
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
}
-
Step 2: Run to see it fail — build error.
-
Step 3: Implement
using System.Text;
using A2A;
using A2AMessage = global::A2A.Message;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>How remote tasks read for the model: state names, the answer text, saved files, result and delivery texts.</summary>
internal static class A2AResults
{
public const string ResponseArtifactId = "response";
/// <summary>Terminal, or waiting for us (input/auth required): nothing more happens without us.</summary>
public static bool IsSettled(TaskState state) =>
state.IsTerminal() || state is TaskState.InputRequired or TaskState.AuthRequired;
public static string StateName(TaskState state) => state switch
{
TaskState.Submitted => "submitted",
TaskState.Working => "working",
TaskState.Completed => "completed",
TaskState.Failed => "failed",
TaskState.Canceled => "canceled",
TaskState.InputRequired => "input-required",
TaskState.Rejected => "rejected",
TaskState.AuthRequired => "auth-required",
_ => "unknown",
};
public static string TextOf(IEnumerable<Part>? parts) =>
string.Concat((parts ?? []).Where(p => p.ContentCase == PartContentCase.Text).Select(p => p.Text));
public static string TextOf(A2AMessage? message) => TextOf(message?.Parts);
/// <summary>The answer: artifact <c>response</c>, else every text artifact part, else the status message.</summary>
public static string? FinalText(AgentTask task)
{
var artifacts = task.Artifacts ?? [];
var response = artifacts.Where(a => a.ArtifactId == ResponseArtifactId).Select(a => TextOf(a.Parts)).FirstOrDefault(t => t.Length > 0);
if (response is not null) return response;
var text = string.Join("\n", artifacts.Select(a => TextOf(a.Parts)).Where(t => t.Length > 0));
if (text.Length > 0) return text;
var status = TextOf(task.Status.Message);
return status.Length > 0 ? status : null;
}
/// <summary>Saves file parts (inline bytes) under <c>inbox/a2a/<agent>/</c>; returns workspace-relative paths.</summary>
public static IReadOnlyList<string> SaveFiles(string workspace, string agent, AgentTask task, long maxBytes)
{
var saved = new List<string>();
foreach (var artifact in task.Artifacts ?? [])
foreach (var part in artifact.Parts ?? [])
{
if (part.ContentCase != PartContentCase.Raw || part.Raw is not { } bytes || bytes.Length > maxBytes) continue;
var name = Sanitize(part.Filename ?? artifact.Name ?? artifact.ArtifactId);
var relative = $"inbox/a2a/{agent}/{name}";
var full = Path.Combine(workspace, relative);
for (var n = 2; File.Exists(full); n++)
{
relative = $"inbox/a2a/{agent}/{Path.GetFileNameWithoutExtension(name)}-{n}{Path.GetExtension(name)}";
full = Path.Combine(workspace, relative);
}
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllBytes(full, bytes);
saved.Add(relative);
}
return saved;
}
/// <summary>Links the remote agent returned instead of bytes (not downloaded).</summary>
public static IReadOnlyList<string> Links(AgentTask task) =>
(task.Artifacts ?? []).SelectMany(a => a.Parts ?? []).Where(p => p.ContentCase == PartContentCase.Url && p.Url is not null)
.Select(p => p.Url!).ToList();
/// <summary>The tool result for a task.</summary>
public static string Describe(string agent, AgentTask task, IReadOnlyList<string> files)
{
var sb = new StringBuilder();
sb.Append($"Agent \"{agent}\": task {task.Id} (context {task.ContextId}) is {StateName(task.Status.State)}.");
AppendBody(sb, task, files);
return sb.ToString();
}
/// <summary>The follow-up prompt a watch delivers.</summary>
public static string DeliveryText(string agent, AgentTask task, IReadOnlyList<string> files)
{
var kind = task.Status.State is TaskState.InputRequired or TaskState.AuthRequired ? "question" : "result";
var sb = new StringBuilder($"[A2A {kind} from \"{agent}\", task {task.Id} ({StateName(task.Status.State)})]");
sb.Append($"\nContext: {task.ContextId}");
AppendBody(sb, task, files);
return sb.ToString();
}
private static void AppendBody(StringBuilder sb, AgentTask task, IReadOnlyList<string> files)
{
if (FinalText(task) is { } text) sb.Append('\n').Append(text);
if (files.Count > 0) sb.Append("\nFiles: ").Append(string.Join(", ", files));
var links = Links(task);
if (links.Count > 0) sb.Append("\nLinks: ").Append(string.Join(", ", links));
if (task.Status.State is TaskState.InputRequired or TaskState.AuthRequired)
sb.Append($"\nThe agent is waiting for an answer: reply with action=send, agent, taskId={task.Id} and your answer.");
}
private static string Sanitize(string? name)
{
var file = Path.GetFileName(name ?? "");
if (string.IsNullOrWhiteSpace(file)) return "file";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(file.Select(c => invalid.Contains(c) || char.IsWhiteSpace(c) ? '_' : c).ToArray());
return clean.Length > 80 ? clean[^80..] : clean;
}
}
-
Step 4: Run tests — Expected: 5 passed.
-
Step 5: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs
git commit -m "feat(a2a-client): describe remote tasks and save their files to the inbox"
Task 4: pending.json
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/PendingFile.cs -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/PendingFileTests.cs -
Step 1: Failing tests
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public sealed class PendingFileTests : IDisposable
{
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-pending", Guid.NewGuid().ToString("N"));
private static WatchRecord Record(string id, string target = "1") =>
new(id, target, "travel-bot", "task-" + id, "ctx", null, DateTimeOffset.UnixEpoch, WatchSources.Poll);
[Fact]
public void AddUpdateRemove_RoundTrips()
{
PendingFile.Upsert(_ws, Record("a"));
PendingFile.Upsert(_ws, Record("b"));
PendingFile.Upsert(_ws, Record("a") with { Undelivered = "text" });
var all = PendingFile.Read(_ws);
Assert.Equal(2, all.Count);
Assert.Equal("text", all.Single(r => r.WatchId == "a").Undelivered);
PendingFile.Remove(_ws, "a");
Assert.Equal("b", Assert.Single(PendingFile.Read(_ws)).WatchId);
}
[Fact]
public void Read_MissingOrMalformed_Empty()
{
Assert.Empty(PendingFile.Read(_ws));
Directory.CreateDirectory(Path.GetDirectoryName(PendingFile.PathFor(_ws))!);
File.WriteAllText(PendingFile.PathFor(_ws), "{ broken");
Assert.Empty(PendingFile.Read(_ws));
}
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
}
-
Step 2: Run to see it fail — build error.
-
Step 3: Implement
using System.Collections.Concurrent;
using System.Text.Json;
namespace Volcanic.AgenticCore.Extensions.A2A;
public static class WatchSources
{
public const string Push = "push";
public const string Subscribe = "subscribe";
public const string Poll = "poll";
}
/// <param name="Target">The host's delivery target (Telegram: the chat id).</param>
/// <param name="PushToken">Our secret for this watch's push webhook; null when not using push.</param>
/// <param name="Undelivered">A finished result that couldn't be delivered yet (sent on the target's next session start).</param>
public sealed record WatchRecord(
string WatchId, string Target, string Agent, string TaskId, string ContextId, string? PushToken,
DateTimeOffset StartedAt, string Source, string? Undelivered = null);
/// <summary><c><workspace>/.volcanic/a2a/pending.json</c>: the watches of a workspace, so they survive restarts.</summary>
public static class PendingFile
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { WriteIndented = true };
private static readonly ConcurrentDictionary<string, object> Locks = new(StringComparer.Ordinal);
public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "a2a", "pending.json");
public static IReadOnlyList<WatchRecord> Read(string workspace)
{
lock (LockFor(workspace)) return ReadUnlocked(workspace);
}
public static void Upsert(string workspace, WatchRecord record) => Change(workspace, list =>
{
list.RemoveAll(r => r.WatchId == record.WatchId);
list.Add(record);
});
public static void Remove(string workspace, string watchId) => Change(workspace, list => list.RemoveAll(r => r.WatchId == watchId));
private static void Change(string workspace, Action<List<WatchRecord>> change)
{
lock (LockFor(workspace))
{
var list = ReadUnlocked(workspace).ToList();
change(list);
var path = PathFor(workspace);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(list, Json));
File.Move(temp, path, overwrite: true);
}
}
private static List<WatchRecord> ReadUnlocked(string workspace)
{
var path = PathFor(workspace);
try
{
return File.Exists(path) ? JsonSerializer.Deserialize<List<WatchRecord>>(File.ReadAllText(path), Json) ?? [] : [];
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return [];
}
}
private static object LockFor(string workspace) => Locks.GetOrAdd(Path.GetFullPath(workspace), _ => new object());
}
-
Step 4: Run tests — Expected: 2 passed.
-
Step 5: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A/PendingFile.cs tests/Volcanic.AgenticCore.Extensions.Tests/A2A/PendingFileTests.cs
git commit -m "feat(a2a-client): persist watches in pending.json"
Task 5: Connecting to remote agents (+ the in-process remote for tests)
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/RemoteAgents.cs -
Create:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/RemoteAgentServer.cs -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/RemoteAgentsTests.cs -
Step 1: The remote server harness
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/RemoteAgentServer.cs — our own A2A server, in process, as "the remote agent":
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.A2A.Server;
using Volcanic.AgenticCore.A2A.Server.Callers;
using Volcanic.AgenticCore.A2A.Server.Hosting;
using Volcanic.AgenticCore.A2A.Server.Push;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
/// <summary>
/// A remote A2A agent: our server library on a TestServer over one workspace, with its own FauxProvider as the remote
/// agent's brain. <see cref="Handler"/> reaches it from an HttpClient; pushes go to <see cref="PushHandler"/>.
/// </summary>
public sealed class RemoteAgentServer : IAsyncDisposable
{
public const string Key = "remote-key-0123456789";
public const string BaseUrl = "http://remote.test/";
private readonly string _root = Path.Combine(Path.GetTempPath(), "a2a-remote", Guid.NewGuid().ToString("N"));
public FauxProvider Faux { get; } = new();
public string Workspace => Path.Combine(_root, "ws");
public WebApplication App { get; private set; } = null!;
public HttpMessageHandler Handler => App.GetTestServer().CreateHandler();
/// <summary>Where the remote's push sender posts. Default: answers 200 to everything.</summary>
public HttpMessageHandler PushHandler { get; set; } = new OkHandler();
public static async Task<RemoteAgentServer> StartAsync(Action<A2AHostOptions>? configure = null)
{
var host = new RemoteAgentServer();
Directory.CreateDirectory(Path.GetDirectoryName(CallersFile.PathFor(host.Workspace))!);
File.WriteAllText(CallersFile.PathFor(host.Workspace), $$"""[ { "name": "client", "key": "{{Key}}" } ]""");
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(host._root, "agent");
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.Extensions.Add(ExtensionRegistration.FromInstance(host.Faux.Extension));
o.ProcessEnvironment.HardenHostProcess = false;
o.Retry = new RetrySettings { Enabled = false };
});
builder.Services.AddSingleton<A2APushSender>(_ => new HttpA2APushSender(
new HttpClient(new DeferredHandler(() => host.PushHandler)), TimeProvider.System, NullLogger<HttpA2APushSender>.Instance));
builder.Services.AddSingleton<IA2ACallerDirectory>(new WorkspaceCallerDirectory(
() => [new A2AWorkspace(host.Workspace, "remote", "remote")], NullLogger<WorkspaceCallerDirectory>.Instance));
builder.Services.AddA2AServer(o =>
{
o.AllowPrivatePushUrls = true;
o.PublicUrl = BaseUrl.TrimEnd('/');
configure?.Invoke(o);
});
host.App = builder.Build();
host.App.MapA2AServer();
await host.App.StartAsync();
return host;
}
public async ValueTask DisposeAsync()
{
await App.StopAsync();
await App.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
private sealed class OkHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
/// <summary>Resolves the target handler per request, so tests can swap <see cref="PushHandler"/> after start.</summary>
private sealed class DeferredHandler(Func<HttpMessageHandler> target) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
new HttpMessageInvoker(target(), disposeHandler: false).SendAsync(request, cancellationToken);
}
}
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/PushToRegistryHandler.cs (create it in Task 6, it needs the registry):
using System.Net;
using System.Text.Json;
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
/// <summary>Stands in for our push receiver endpoint: <c>POST …/a2a/push/{watchId}</c> → <see cref="A2AWatchRegistry.OnPushAsync"/>.</summary>
public sealed class PushToRegistryHandler(A2AWatchRegistry? registry) : HttpMessageHandler
{
public int Received;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Interlocked.Increment(ref Received);
if (registry is null) return new HttpResponseMessage(HttpStatusCode.OK);
var watchId = request.RequestUri!.Segments[^1];
var token = request.Headers.TryGetValues("X-A2A-Notification-Token", out var v) ? v.Single() : null;
using var doc = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken));
var ok = await registry.OnPushAsync(watchId, token, doc.RootElement.Clone(), cancellationToken);
return new HttpResponseMessage(ok ? HttpStatusCode.OK : HttpStatusCode.NotFound);
}
}
(Only RemoteAgentServer.cs is created in this task.)
- Step 2: Failing tests
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/RemoteAgentsTests.cs:
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public class RemoteAgentsTests
{
[Fact]
public async Task Connect_FetchesExtendedCardWithKey()
{
await using var remote = await RemoteAgentServer.StartAsync();
File.WriteAllText(Path.Combine(remote.Workspace, ".volcanic", "a2a", "card.json"),
"""{ "name": "Remote travel", "description": "Plans trips", "skills": [ { "id": "trips", "name": "Trips", "description": "Plans trips" } ] }""");
var connector = new RemoteAgentConnector(remote.Handler, TimeSpan.FromSeconds(10));
using var agent = await connector.ConnectAsync(new RemoteAgentEntry("travel", RemoteAgentServer.BaseUrl, RemoteAgentServer.Key, null), CancellationToken.None);
Assert.Equal("Remote travel", agent.Card.Name);
Assert.True(agent.Streaming);
Assert.True(agent.SupportsPush);
}
[Fact]
public async Task Connect_WithoutKey_PublicCardOnly()
{
await using var remote = await RemoteAgentServer.StartAsync();
var connector = new RemoteAgentConnector(remote.Handler, TimeSpan.FromSeconds(10));
using var agent = await connector.ConnectAsync(new RemoteAgentEntry("travel", RemoteAgentServer.BaseUrl, null, null), CancellationToken.None);
Assert.Equal("Volcanic assistant", agent.Card.Name);
}
[Fact]
public void CardUrl_BaseOrExplicitJson()
{
Assert.Equal("https://a.example/x/.well-known/agent-card.json", RemoteAgentConnector.CardUrl("https://a.example/x").ToString());
Assert.Equal("https://a.example/card.json", RemoteAgentConnector.CardUrl("https://a.example/card.json").ToString());
}
}
-
Step 3: Run to see it fail — build error.
-
Step 4: Implement
extensions/Volcanic.AgenticCore.Extensions.A2A/RemoteAgents.cs:
using A2A;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>A connected remote agent: its (extended) card and a client that sends the key.</summary>
internal sealed class RemoteAgent(RemoteAgentEntry entry, AgentCard card, IA2AClient client, HttpClient http) : IDisposable
{
public RemoteAgentEntry Entry => entry;
public AgentCard Card => card;
public IA2AClient Client => client;
public bool Streaming => card.Capabilities?.Streaming == true;
public bool SupportsPush => card.Capabilities?.PushNotifications == true;
public void Dispose()
{
(client as IDisposable)?.Dispose();
http.Dispose();
}
}
/// <summary>Resolves an agent's card (public, then extended when we have a key) and builds a client over one shared handler.</summary>
internal sealed class RemoteAgentConnector(HttpMessageHandler handler, TimeSpan cardTimeout)
{
public static Uri CardUrl(string url) =>
url.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
? new Uri(url)
: new Uri(new Uri(url.TrimEnd('/') + "/"), ".well-known/agent-card.json");
public async Task<RemoteAgent> ConnectAsync(RemoteAgentEntry entry, CancellationToken cancellationToken)
{
var cardUrl = CardUrl(entry.Url);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(cardTimeout);
AgentCard card;
using (var publicHttp = new HttpClient(handler, disposeHandler: false))
card = await new A2ACardResolver(new Uri(cardUrl.GetLeftPart(UriPartial.Authority)), publicHttp, cardUrl.PathAndQuery)
.GetAgentCardAsync(timeout.Token).ConfigureAwait(false);
var http = new HttpClient(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan };
if (entry.Key is { Length: > 0 } key)
{
if (UsesBearer(card)) http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", key);
else http.DefaultRequestHeaders.Add(ApiKeyHeader(card), key);
}
var client = A2AClientFactory.Create(card, http);
if (entry.Key is not null && card.Capabilities?.ExtendedAgentCard == true)
{
try
{
var extended = await client.GetExtendedAgentCardAsync(new GetExtendedAgentCardRequest(), timeout.Token).ConfigureAwait(false);
(client as IDisposable)?.Dispose();
card = extended;
client = A2AClientFactory.Create(card, http);
}
catch (Exception ex) when (ex is A2AException or HttpRequestException) { } // keep the public card
}
return new RemoteAgent(entry, card, client, http);
}
private static bool UsesBearer(AgentCard card) =>
card.SecuritySchemes is { Count: > 0 } schemes &&
schemes.Values.All(s => s.HttpAuthSecurityScheme is { Scheme: var scheme } && string.Equals(scheme, "bearer", StringComparison.OrdinalIgnoreCase));
private static string ApiKeyHeader(AgentCard card) =>
card.SecuritySchemes?.Values.Select(s => s.ApiKeySecurityScheme)
.FirstOrDefault(k => k is not null && string.Equals(k.Location, "header", StringComparison.OrdinalIgnoreCase))?.Name
?? "X-API-Key";
}
(A2ACardResolver with an authority-only base and a path: check its URL combination in the decompiled SDK if a test fails; the goal is cardUrl exactly.)
-
Step 5: Run tests — Expected: 3 passed.
-
Step 6: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A/RemoteAgents.cs tests/Volcanic.AgenticCore.Extensions.Tests/A2A
git commit -m "feat(a2a-client): connect to remote agents through their cards with the configured key"
Task 6: Delivery and the watch registry
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResultDelivery.cs -
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AWatchRegistry.cs -
Create:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/PushToRegistryHandler.cs(code in Task 5 Step 1) -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AWatchTests.cs -
Step 1: Delivery
using System.Collections.Concurrent;
using Volcanic.AgenticCore.Abstractions.Extensions;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>Where finished remote tasks go. Targets are host ids (Telegram: chat ids).</summary>
public interface IA2AResultDelivery
{
/// <summary>Run <paramref name="text"/> as a follow-up prompt in the target's conversation. False: not deliverable now.</summary>
Task<bool> DeliverAsync(string target, string text, CancellationToken cancellationToken);
/// <summary>The target's workspace. A path computation: must not create or wake a session.</summary>
string? WorkspaceFor(string target);
/// <summary>Targets whose workspace may hold a pending.json (restore at startup).</summary>
IEnumerable<string> KnownTargets();
}
/// <summary>Default delivery: the target's live session, if any (registered on session start, removed on shutdown).</summary>
public sealed class LiveSessionDelivery : IA2AResultDelivery
{
private readonly ConcurrentDictionary<string, (IExtensionApi Api, string Workspace)> _live = new(StringComparer.Ordinal);
public void Attach(string target, string workspace, IExtensionApi api) => _live[target] = (api, workspace);
public void Detach(string target, IExtensionApi api)
{
if (_live.TryGetValue(target, out var entry) && ReferenceEquals(entry.Api, api)) _live.TryRemove(target, out _);
}
public Task<bool> DeliverAsync(string target, string text, CancellationToken cancellationToken)
{
if (!_live.TryGetValue(target, out var entry)) return Task.FromResult(false);
try
{
entry.Api.SendUserMessage(text, null, MessageDelivery.FollowUp);
return Task.FromResult(true);
}
catch (Exception ex) when (ex is InvalidOperationException or ObjectDisposedException)
{
return Task.FromResult(false);
}
}
public string? WorkspaceFor(string target) => _live.TryGetValue(target, out var entry) ? entry.Workspace : null;
public IEnumerable<string> KnownTargets() => _live.Keys.ToList();
}
- Step 2: Failing tests (the registry is exercised against the in-process remote)
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AWatchTests.cs:
using System.Collections.Concurrent;
using System.Text.Json;
using A2A;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
using Volcanic.AgenticCore.Testing;
using A2AMessage = global::A2A.Message;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public sealed class A2AWatchTests : IDisposable
{
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-watch", Guid.NewGuid().ToString("N"));
private sealed class RecordingDelivery(string workspace) : IA2AResultDelivery
{
public ConcurrentQueue<(string Target, string Text)> Delivered { get; } = new();
public bool Accept { get; set; } = true;
public Task<bool> DeliverAsync(string target, string text, CancellationToken cancellationToken)
{
if (Accept) Delivered.Enqueue((target, text));
return Task.FromResult(Accept);
}
public string? WorkspaceFor(string target) => workspace;
public IEnumerable<string> KnownTargets() => ["1"];
}
private void WriteAgents(string key = RemoteAgentServer.Key)
{
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(_ws))!);
File.WriteAllText(AgentsFile.PathFor(_ws), $$"""[ { "name": "remote", "url": "{{RemoteAgentServer.BaseUrl}}", "key": "{{key}}" } ]""");
}
private static async Task<AgentTask> StartRemoteTaskAsync(RemoteAgent agent, string text = "go") =>
(await agent.Client.SendMessageAsync(new SendMessageRequest
{
Message = new A2AMessage { Role = Role.User, MessageId = Guid.NewGuid().ToString("N"), Parts = [Part.FromText(text)] },
Configuration = new SendMessageConfiguration { ReturnImmediately = true },
})).Task!;
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 10_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
await Task.Delay(20);
}
}
[Theory]
[InlineData(WatchSources.Subscribe)]
[InlineData(WatchSources.Poll)]
public async Task Watch_DeliversResult(string source)
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Trip booked").WithDelay(TimeSpan.FromMilliseconds(500)));
WriteAgents();
var delivery = new RecordingDelivery(_ws);
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { SourceOverride = source, PollDelays = [TimeSpan.FromMilliseconds(100)] };
registry.UseDelivery(delivery);
var settings = new A2AClientSettings();
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
var task = await StartRemoteTaskAsync(agent);
var record = await registry.StartAsync("1", _ws, agent, task, settings, CancellationToken.None);
Assert.Equal(source, record.Source);
Assert.Single(PendingFile.Read(_ws));
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty);
var (target, text) = Assert.Single(delivery.Delivered);
Assert.Equal("1", target);
Assert.StartsWith($"[A2A result from \"remote\", task {task.Id} (completed)]", text);
Assert.Contains("Trip booked", text);
await WaitUntilAsync(() => PendingFile.Read(_ws).Count == 0);
}
[Fact]
public async Task Watch_Push_DeliversFollowUp()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Pushed result").WithDelay(TimeSpan.FromMilliseconds(500)));
WriteAgents();
var delivery = new RecordingDelivery(_ws);
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { SafetyPollInterval = TimeSpan.FromMinutes(10) };
registry.UseDelivery(delivery);
var push = new PushToRegistryHandler(registry);
remote.PushHandler = push;
var settings = new A2AClientSettings { PushBaseUrl = "http://local.test" };
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
var task = await StartRemoteTaskAsync(agent);
var record = await registry.StartAsync("1", _ws, agent, task, settings, CancellationToken.None);
Assert.Equal(WatchSources.Push, record.Source);
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty);
Assert.True(push.Received > 0);
Assert.Contains("Pushed result", Assert.Single(delivery.Delivered).Text);
}
[Fact]
public async Task Watch_PushBadToken_Rejected()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(5)));
WriteAgents();
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler);
registry.UseDelivery(new RecordingDelivery(_ws));
var settings = new A2AClientSettings { PushBaseUrl = "http://local.test" };
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
var record = await registry.StartAsync("1", _ws, agent, await StartRemoteTaskAsync(agent), settings, CancellationToken.None);
var body = JsonDocument.Parse("{}").RootElement;
Assert.False(await registry.OnPushAsync(record.WatchId, "wrong", body, CancellationToken.None));
Assert.False(await registry.OnPushAsync("unknown", record.PushToken, body, CancellationToken.None));
Assert.True(await registry.OnPushAsync(record.WatchId, record.PushToken, body, CancellationToken.None));
}
[Fact]
public async Task Watch_Timeout_DeliversNotice()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("never").WithDelay(TimeSpan.FromSeconds(10)));
WriteAgents();
var delivery = new RecordingDelivery(_ws);
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { SourceOverride = WatchSources.Poll, PollDelays = [TimeSpan.FromMilliseconds(100)] };
registry.UseDelivery(delivery);
var settings = new A2AClientSettings { WatchTimeout = TimeSpan.FromMilliseconds(400) };
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
await registry.StartAsync("1", _ws, agent, await StartRemoteTaskAsync(agent), settings, CancellationToken.None);
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty);
Assert.Contains("gave no result", Assert.Single(delivery.Delivered).Text);
}
[Fact]
public async Task Watch_AgentRemoved_EndsWatch()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("never").WithDelay(TimeSpan.FromSeconds(10)));
WriteAgents();
var delivery = new RecordingDelivery(_ws);
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { SourceOverride = WatchSources.Poll, PollDelays = [TimeSpan.FromMilliseconds(100)] };
registry.UseDelivery(delivery);
var settings = new A2AClientSettings();
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
await registry.StartAsync("1", _ws, agent, await StartRemoteTaskAsync(agent), settings, CancellationToken.None);
File.WriteAllText(AgentsFile.PathFor(_ws), "[]");
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty);
Assert.Contains("no longer in agents.json", Assert.Single(delivery.Delivered).Text);
Assert.Empty(registry.List("1"));
}
[Fact]
public async Task Watch_Undelivered_KeptThenFlushed()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Later"));
WriteAgents();
var delivery = new RecordingDelivery(_ws) { Accept = false };
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { SourceOverride = WatchSources.Poll, PollDelays = [TimeSpan.FromMilliseconds(100)] };
registry.UseDelivery(delivery);
var settings = new A2AClientSettings();
using var agent = await registry.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
await registry.StartAsync("1", _ws, agent, await StartRemoteTaskAsync(agent), settings, CancellationToken.None);
await WaitUntilAsync(() => PendingFile.Read(_ws).Any(r => r.Undelivered is not null));
delivery.Accept = true;
await registry.FlushUndeliveredAsync("1", _ws, CancellationToken.None);
Assert.Contains("Later", Assert.Single(delivery.Delivered).Text);
Assert.Empty(PendingFile.Read(_ws));
}
[Fact]
public async Task Watch_Restore_FromPendingFile()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Done before restart"));
WriteAgents();
var settings = new A2AClientSettings();
AgentTask task;
await using (var first = new A2AWatchRegistry(TimeProvider.System, remote.Handler))
{
using var agent = await first.Connector(settings).ConnectAsync(AgentsFile.Find(_ws, "remote")!, CancellationToken.None);
task = await StartRemoteTaskAsync(agent);
}
PendingFile.Upsert(_ws, new WatchRecord("w1", "1", "remote", task.Id, task.ContextId, null, DateTimeOffset.UtcNow, WatchSources.Poll));
var delivery = new RecordingDelivery(_ws);
await using var registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { PollDelays = [TimeSpan.FromMilliseconds(100)] };
registry.UseDelivery(delivery);
Assert.Equal(1, await registry.RestoreAsync(settings, CancellationToken.None));
await WaitUntilAsync(() => !delivery.Delivered.IsEmpty);
Assert.Contains("Done before restart", Assert.Single(delivery.Delivered).Text);
}
[Fact]
public void PollDelays_BackOffToFiveMinutes()
{
var delays = A2AWatchRegistry.DefaultPollDelays;
Assert.Equal(TimeSpan.FromSeconds(15), delays[0]);
Assert.Equal(TimeSpan.FromMinutes(5), delays[^1]);
Assert.Equal(TimeSpan.FromMinutes(5), A2AWatchRegistry.DelayAt(delays, 99));
}
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
}
-
Step 3: Run to see it fail — build error.
-
Step 4: Implement the registry
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AWatchRegistry.cs:
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>
/// Process-wide (<see cref="Shared"/>): one HTTP handler for every remote call, and the watches of long remote tasks.
/// A watch follows its task by push (when the card supports it and the host gave us a push base URL), else by
/// subscribing (streaming agents), else by polling; it delivers the outcome through <see cref="IA2AResultDelivery"/> and
/// is persisted in the workspace's <c>pending.json</c> until delivered.
/// </summary>
public sealed class A2AWatchRegistry : IAsyncDisposable
{
public static A2AWatchRegistry Shared { get; } = new(TimeProvider.System,
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) });
public static readonly TimeSpan[] DefaultPollDelays =
[TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5)];
private static readonly TimeSpan[] ReconnectDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)];
private readonly HttpMessageHandler _handler;
private readonly ConcurrentDictionary<string, Watch> _watches = new(StringComparer.Ordinal);
private IA2AResultDelivery _delivery;
public A2AWatchRegistry(TimeProvider time, HttpMessageHandler handler, ILogger? logger = null)
{
Time = time;
_handler = handler;
Logger = logger ?? NullLogger.Instance;
_delivery = Live;
}
public TimeProvider Time { get; }
public ILogger Logger { get; set; }
public LiveSessionDelivery Live { get; } = new();
internal string? SourceOverride { get; init; }
internal TimeSpan[] PollDelays { get; init; } = DefaultPollDelays;
internal TimeSpan SafetyPollInterval { get; init; } = TimeSpan.FromMinutes(10);
public void UseDelivery(IA2AResultDelivery delivery) => _delivery = delivery;
internal RemoteAgentConnector Connector(A2AClientSettings settings) =>
new(_handler, TimeSpan.FromSeconds(settings.CardTimeoutSeconds));
internal static TimeSpan DelayAt(TimeSpan[] delays, int attempt) => delays[Math.Min(attempt, delays.Length - 1)];
public IReadOnlyList<WatchRecord> List(string target) =>
_watches.Values.Where(w => w.Record.Target == target).Select(w => w.Record).ToList();
/// <summary>Hand a still-running remote task to a watch.</summary>
internal async Task<WatchRecord> StartAsync(string target, string workspace, RemoteAgent agent, AgentTask task,
A2AClientSettings settings, CancellationToken cancellationToken)
{
var watchId = Guid.NewGuid().ToString("N");
string? token = null;
var source = SourceOverride ?? (agent.Streaming ? WatchSources.Subscribe : WatchSources.Poll);
if (SourceOverride is null && settings.PushBaseUrl is { Length: > 0 } pushBase && agent.SupportsPush)
{
token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
try
{
await agent.Client.CreateTaskPushNotificationConfigAsync(new CreateTaskPushNotificationConfigRequest
{
TaskId = task.Id,
ConfigId = watchId,
Config = new PushNotificationConfig { Id = watchId, Url = $"{pushBase.TrimEnd('/')}/a2a/push/{watchId}", Token = token },
}, cancellationToken).ConfigureAwait(false);
source = WatchSources.Push;
}
catch (Exception ex) when (ex is A2AException or HttpRequestException)
{
Logger.LogInformation("A2A: {Agent} refused push for task {TaskId} ({Error}); watching by {Source}", agent.Entry.Name, task.Id, ex.Message, source);
token = null;
}
}
var record = new WatchRecord(watchId, target, agent.Entry.Name, task.Id, task.ContextId, token, Time.GetUtcNow(), source);
PendingFile.Upsert(workspace, record);
Arm(record, workspace, settings);
return record;
}
/// <summary>Push receiver: a known watch with the right token wakes up and checks its task. False → 404.</summary>
public Task<bool> OnPushAsync(string watchId, string? token, JsonElement body, CancellationToken cancellationToken)
{
if (!_watches.TryGetValue(watchId, out var watch) || watch.Record.PushToken is not { } expected || token is null ||
!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(token)))
return Task.FromResult(false);
watch.Wake.Writer.TryWrite(true);
return Task.FromResult(true);
}
/// <summary>Re-arm every persisted watch of the delivery's known targets; delivers results kept undelivered. Returns the count re-armed.</summary>
public async Task<int> RestoreAsync(A2AClientSettings settings, CancellationToken cancellationToken)
{
var count = 0;
foreach (var target in _delivery.KnownTargets().Distinct(StringComparer.Ordinal))
{
if (_delivery.WorkspaceFor(target) is not { } workspace) continue;
await FlushUndeliveredAsync(target, workspace, cancellationToken).ConfigureAwait(false);
foreach (var record in PendingFile.Read(workspace).Where(r => r.Target == target && r.Undelivered is null))
{
if (_watches.ContainsKey(record.WatchId)) continue;
Arm(record, workspace, settings);
count++;
}
}
return count;
}
/// <summary>Deliver results that were kept because the target had no live conversation.</summary>
public async Task FlushUndeliveredAsync(string target, string workspace, CancellationToken cancellationToken)
{
foreach (var record in PendingFile.Read(workspace).Where(r => r.Target == target && r.Undelivered is not null))
if (await TryDeliverAsync(target, record.Undelivered!, cancellationToken).ConfigureAwait(false))
PendingFile.Remove(workspace, record.WatchId);
}
/// <summary>Stop watching a task (after <c>cancel</c>); true when a watch existed.</summary>
public async Task<bool> StopWatchingAsync(string target, string workspace, string taskId)
{
var watch = _watches.Values.FirstOrDefault(w => w.Record.Target == target && w.Record.TaskId == taskId);
foreach (var record in PendingFile.Read(workspace).Where(r => r.Target == target && r.TaskId == taskId))
PendingFile.Remove(workspace, record.WatchId);
if (watch is null) return false;
await watch.StopAsync().ConfigureAwait(false);
return true;
}
private void Arm(WatchRecord record, string workspace, A2AClientSettings settings)
{
var watch = new Watch(record, workspace);
if (!_watches.TryAdd(record.WatchId, watch)) return;
watch.Loop = Task.Run(() => RunAsync(watch, settings));
}
private async Task RunAsync(Watch watch, A2AClientSettings settings)
{
var record = watch.Record;
var ct = watch.Stop.Token;
var deadline = record.StartedAt + settings.WatchTimeout;
var source = record.Source;
var failures = 0;
var polls = 0;
try
{
while (!ct.IsCancellationRequested)
{
if (Time.GetUtcNow() >= deadline)
{
await FinishAsync(watch, $"[A2A result from \"{record.Agent}\", task {record.TaskId} (no result)]\n" +
$"\"{record.Agent}\" gave no result within {settings.WatchTimeout}. Check it with action=status, agent={record.Agent}, taskId={record.TaskId}.", null).ConfigureAwait(false);
return;
}
if (AgentsFile.Find(watch.Workspace, record.Agent) is not { } entry)
{
await FinishAsync(watch, $"[A2A result from \"{record.Agent}\", task {record.TaskId} (stopped)]\n" +
$"\"{record.Agent}\" is no longer in agents.json, so its task is no longer watched.", null).ConfigureAwait(false);
return;
}
AgentTask? task = null;
TimeSpan wait;
try
{
using var agent = await Connector(settings).ConnectAsync(entry, ct).ConfigureAwait(false);
task = source == WatchSources.Subscribe
? await SubscribeAsync(agent, record.TaskId, deadline, ct).ConfigureAwait(false)
: await agent.Client.GetTaskAsync(new GetTaskRequest { Id = record.TaskId }, ct).ConfigureAwait(false);
if (task is not null && A2AResults.IsSettled(task.Status.State))
{
var files = A2AResults.SaveFiles(watch.Workspace, record.Agent, task, settings.MaxFileBytes);
await FinishAsync(watch, A2AResults.DeliveryText(record.Agent, task, files), agent).ConfigureAwait(false);
return;
}
failures = 0;
wait = source switch
{
WatchSources.Push => SafetyPollInterval,
WatchSources.Subscribe => DelayAt(ReconnectDelays, 0),
_ => DelayAt(PollDelays, polls++),
};
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
failures++;
Logger.LogInformation("A2A: watching task {TaskId} of {Agent} failed ({Error})", record.TaskId, record.Agent, ex.Message);
if (source == WatchSources.Subscribe && failures >= 3) source = WatchSources.Poll;
wait = source == WatchSources.Subscribe ? DelayAt(ReconnectDelays, failures) : DelayAt(PollDelays, polls++);
}
await WaitAsync(watch, Min(wait, deadline - Time.GetUtcNow()), ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
}
catch (Exception ex)
{
Logger.LogWarning(ex, "A2A: watch {WatchId} crashed", record.WatchId);
}
}
private async Task<AgentTask?> SubscribeAsync(RemoteAgent agent, string taskId, DateTimeOffset deadline, CancellationToken ct)
{
AgentTask? current = null;
try
{
await foreach (var ev in agent.Client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, ct).ConfigureAwait(false))
{
current = TaskProjection.Apply(current, ev);
if (current is not null && A2AResults.IsSettled(current.Status.State)) return current;
if (Time.GetUtcNow() >= deadline) break;
}
}
catch (A2AException) // e.g. the task is already terminal: read it instead
{
}
return await agent.Client.GetTaskAsync(new GetTaskRequest { Id = taskId }, ct).ConfigureAwait(false);
}
private async Task WaitAsync(Watch watch, TimeSpan wait, CancellationToken ct)
{
if (wait <= TimeSpan.Zero) return;
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
var delay = Task.Delay(wait, Time, timeout.Token);
var woken = watch.Wake.Reader.WaitToReadAsync(timeout.Token).AsTask();
await Task.WhenAny(delay, woken).ConfigureAwait(false);
await timeout.CancelAsync().ConfigureAwait(false);
while (watch.Wake.Reader.TryRead(out _)) { }
ct.ThrowIfCancellationRequested();
}
private async Task FinishAsync(Watch watch, string text, RemoteAgent? agent)
{
_watches.TryRemove(watch.Record.WatchId, out _);
if (agent is not null && watch.Record.PushToken is not null)
{
try
{
await agent.Client.DeleteTaskPushNotificationConfigAsync(new DeleteTaskPushNotificationConfigRequest
{ TaskId = watch.Record.TaskId, Id = watch.Record.WatchId }, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (ex is A2AException or HttpRequestException) { }
}
if (await TryDeliverAsync(watch.Record.Target, text, CancellationToken.None).ConfigureAwait(false))
PendingFile.Remove(watch.Workspace, watch.Record.WatchId);
else
PendingFile.Upsert(watch.Workspace, watch.Record with { Undelivered = text });
}
private async Task<bool> TryDeliverAsync(string target, string text, CancellationToken ct)
{
try
{
return await _delivery.DeliverAsync(target, text, ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Logger.LogWarning(ex, "A2A: delivering a result to {Target} failed", target);
return false;
}
}
private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;
public async Task StopAsync()
{
foreach (var watch in _watches.Values) await watch.StopAsync().ConfigureAwait(false);
_watches.Clear();
}
public async ValueTask DisposeAsync() => await StopAsync().ConfigureAwait(false);
private sealed class Watch(WatchRecord record, string workspace)
{
public WatchRecord Record => record;
public string Workspace => workspace;
public CancellationTokenSource Stop { get; } = new();
public Channel<bool> Wake { get; } = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
public Task Loop { get; set; } = Task.CompletedTask;
public async Task StopAsync()
{
await Stop.CancelAsync().ConfigureAwait(false);
try { await Loop.ConfigureAwait(false); } catch (OperationCanceledException) { }
}
}
}
Notes for the engineer:
-
RemoteAgentisinternal, soStartAsync/Connectorare internal too; the tool (Task 7) is in the same assembly and tests see internals. -
WaitAsyncwakes early on a push; a push only means "check now" — its body is never trusted. -
The subscribe branch returns the latest task (via
GetTask) when the stream ends without settling; the loop then reconnects after 1 s. -
Step 5: Run tests
Run: dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~A2AWatchTests"
Expected: 9 passed. Run it 3 more times to check for flakiness.
- Step 6: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A tests/Volcanic.AgenticCore.Extensions.Tests/A2A
git commit -m "feat(a2a-client): watch long remote tasks by push, subscribe or poll and deliver the results"
Task 7: The a2a tool and the extension
Files:
-
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2ATool.cs -
Create:
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AClientExtension.cs -
Create:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/A2AClientHost.cs -
Test:
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AToolTests.cs,A2AHopsTests.cs -
Step 1: The client test host
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/Support/A2AClientHost.cs:
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Features.Agent;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Sessions;
using Volcanic.AgenticCore.Hosting;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
/// <summary>Our assistant: a core host whose sessions get <see cref="A2AClientExtension"/> (target "chat-1").</summary>
public sealed class A2AClientHost : IAsyncDisposable
{
public const string Target = "chat-1";
private readonly string _root = Path.Combine(Path.GetTempPath(), "a2a-client", Guid.NewGuid().ToString("N"));
private readonly ServiceProvider _services;
private readonly List<AgentSession> _sessions = [];
public A2AClientHost(RemoteAgentServer remote, A2AClientSettings? settings = null, params IExtension[] extra)
{
Directory.CreateDirectory(Workspace);
Settings = settings ?? new A2AClientSettings { Enabled = true, WaitSeconds = 10 };
Registry = new A2AWatchRegistry(TimeProvider.System, remote.Handler) { PollDelays = [TimeSpan.FromMilliseconds(100)] };
var services = new ServiceCollection();
services.AddAgenticCore(o =>
{
o.AgentDir = Path.Combine(_root, "agent");
o.DiscoverExtensionAssemblies = false;
o.DefaultModel = FauxProvider.ModelKey;
o.SessionStoreFactory = _ => new InMemorySessionStore();
o.ProcessEnvironment.HardenHostProcess = false;
o.Retry = new RetrySettings { Enabled = false };
o.Extensions.Add(ExtensionRegistration.FromInstance(Faux.Extension));
foreach (var e in extra) o.Extensions.Add(ExtensionRegistration.FromInstance(e));
});
_services = services.BuildServiceProvider();
WriteAgents($$"""[ { "name": "remote", "url": "{{RemoteAgentServer.BaseUrl}}", "key": "{{RemoteAgentServer.Key}}" } ]""");
}
public FauxProvider Faux { get; } = new();
public A2AClientSettings Settings { get; }
public A2AWatchRegistry Registry { get; }
public string Workspace => Path.Combine(_root, "ws");
public List<AgentSessionEvent> Events { get; } = [];
public void WriteAgents(string json)
{
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(Workspace))!);
File.WriteAllText(AgentsFile.PathFor(Workspace), json);
}
public async Task<AgentSession> CreateSessionAsync()
{
var session = await _services.GetRequiredService<IAgentSessionFactory>().CreateAsync(new AgentSessionOptions
{
Cwd = Workspace,
ExtraExtensions = [ExtensionRegistration.FromInstance(new A2AClientExtension(Settings, Target, Registry))],
});
session.Subscribe(e => { lock (Events) Events.Add(e); });
lock (_sessions) _sessions.Add(session);
return session;
}
/// <summary>Has the local model call the a2a tool once, then say "ok"; returns the tool result text and error flag.</summary>
public async Task<(string Text, bool IsError)> CallToolAsync(AgentSession session, object args)
{
Faux.Enqueue(FauxTurn.Tool("a2a", args), FauxTurn.Say("ok"));
await session.PromptAsync("use the remote agent");
AgentSessionEvent.ToolExecutionEnd end;
lock (Events) end = Events.OfType<AgentSessionEvent.ToolExecutionEnd>().Last(e => e.ToolName == "a2a");
return (end.Result.TextContent, end.IsError);
}
public async ValueTask DisposeAsync()
{
await Registry.DisposeAsync();
foreach (var session in _sessions) await session.DisposeAsync();
await _services.DisposeAsync();
try { Directory.Delete(_root, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
}
- Step 2: Failing tool tests
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AToolTests.cs:
using System.Text;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public class A2AToolTests
{
private static string Field(string text, string label) =>
System.Text.RegularExpressions.Regex.Match(text, label + @" ([A-Za-z0-9_-]+)").Groups[1].Value;
[Fact]
public async Task Agents_ListsCardsAndSkills()
{
await using var remote = await RemoteAgentServer.StartAsync();
File.WriteAllText(Path.Combine(remote.Workspace, ".volcanic", "a2a", "card.json"),
"""{ "name": "Remote travel", "description": "Plans trips", "skills": [ { "id": "trips", "name": "Trips", "description": "Plans trips", "examples": ["Find a flight"] } ] }""");
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "agents" });
Assert.False(isError);
Assert.Contains("remote", text);
Assert.Contains("Plans trips", text);
Assert.Contains("trips", text);
Assert.DoesNotContain(RemoteAgentServer.Key, text);
}
[Fact]
public async Task Agents_UnreachableAgent_Listed()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, CardTimeoutSeconds = 2 });
host.WriteAgents("""[ { "name": "ghost", "url": "http://nowhere.invalid/" } ]""");
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "agents" });
Assert.Contains("ghost", text);
Assert.Contains("unreachable", text);
}
[Fact]
public async Task Send_CompletesWithinWait_ReturnsAnswerAndIds()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Paris is lovely"));
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Where should I go?" });
Assert.False(isError);
Assert.Contains("completed", text);
Assert.Contains("Paris is lovely", text);
Assert.NotEmpty(Field(text, "task"));
Assert.NotEmpty(Field(text, "context"));
}
[Fact]
public async Task Send_ContinuesContext()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("first"), FauxTurn.Say("second"));
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (first, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "one" });
await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "two", contextId = Field(first, "context") });
Assert.Contains("first", string.Join("\n", remote.Faux.Requests.Last().Messages.Select(m => m.Text)));
}
[Fact]
public async Task Send_InputRequired_ReturnsQuestion_AndAnswerContinues()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Tool("request_input", new { question = "Which date?" }), FauxTurn.Say("Booked Friday"));
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (question, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Book a table" });
Assert.Contains("input-required", question);
Assert.Contains("Which date?", question);
var (answer, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "Friday", taskId = Field(question, "task") });
Assert.Contains("completed", answer);
Assert.Contains("Booked Friday", answer);
}
[Fact]
public async Task Send_SlowTask_StartsWatch_AndResultArrivesAsFollowUp()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("Slow answer").WithDelay(TimeSpan.FromMilliseconds(1500)));
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 });
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "take your time" });
Assert.Contains("working", text);
Assert.Contains("delivered", text);
Assert.Single(host.Registry.List(A2AClientHost.Target));
host.Faux.Enqueue(FauxTurn.Say("Thanks, noted"));
var deadline = DateTime.UtcNow.AddSeconds(15);
while (!host.Faux.Requests.Any(r => r.Messages.Any(m => m.Text.Contains("[A2A result from \"remote\""))) && DateTime.UtcNow < deadline)
await Task.Delay(50);
Assert.Contains(host.Faux.Requests, r => r.Messages.Any(m => m.Text.Contains("Slow answer")));
}
[Fact]
public async Task Send_FilesArgument_SentAsParts_AndFileArtifactSaved()
{
await using var remote = await RemoteAgentServer.StartAsync();
File.WriteAllText(Path.Combine(remote.Workspace, "reply.txt"), "remote file");
remote.Faux.Enqueue(FauxTurn.Tool("send", new { path = "reply.txt" }), FauxTurn.Say("sent it"));
await using var host = new A2AClientHost(remote);
File.WriteAllText(Path.Combine(host.Workspace, "notes.txt"), "my notes");
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "here", files = new[] { "notes.txt" } });
Assert.Contains(remote.Faux.Requests.Last().Messages, m => m.Text.Contains("Files: inbox/a2a-client-"));
Assert.Contains("inbox/a2a/remote/reply.txt", text);
Assert.Equal("remote file", File.ReadAllText(Path.Combine(host.Workspace, "inbox", "a2a", "remote", "reply.txt")));
}
[Fact]
public async Task Send_FileOutsideWorkspace_Error()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { "../../etc/passwd" } });
Assert.True(isError);
Assert.Contains("workspace", text);
}
[Fact]
public async Task Send_UnknownAgent_Error()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "nobody", message = "hi" });
Assert.True(isError);
Assert.Contains("nobody", text);
Assert.Contains("remote", text); // lists the configured ones
}
[Fact]
public async Task Send_401_ReadableError_AndResultNeverContainsKey()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
host.WriteAgents($$"""[ { "name": "remote", "url": "{{RemoteAgentServer.BaseUrl}}", "key": "wrong-key-abcdefghij" } ]""");
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "hi" });
Assert.True(isError);
Assert.Contains("rejected", text);
Assert.DoesNotContain("wrong-key-abcdefghij", text);
}
[Fact]
public async Task Status_ReturnsTask_AndCancel_CancelsRemoteAndDropsWatch()
{
await using var remote = await RemoteAgentServer.StartAsync();
remote.Faux.Enqueue(FauxTurn.Say("slow").WithDelay(TimeSpan.FromSeconds(10)));
await using var host = new A2AClientHost(remote, new A2AClientSettings { Enabled = true, WaitSeconds = 0 });
var session = await host.CreateSessionAsync();
var (started, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "go" });
var taskId = Field(started, "task");
var (status, _) = await host.CallToolAsync(session, new { action = "status", agent = "remote", taskId });
Assert.Contains("working", status);
var (cancelled, _) = await host.CallToolAsync(session, new { action = "cancel", agent = "remote", taskId });
Assert.Contains("canceled", cancelled);
Assert.Empty(host.Registry.List(A2AClientHost.Target));
Assert.Empty(PendingFile.Read(host.Workspace));
}
[Fact]
public async Task NoAgentsConfigured_ExplainsFile()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
File.Delete(AgentsFile.PathFor(host.Workspace));
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "agents" });
Assert.Contains(".volcanic/a2a/agents.json", text);
}
}
tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AHopsTests.cs:
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Json;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Extensions.Tests.A2A.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Extensions.Tests.A2A;
public class A2AHopsTests
{
/// <summary>Plays the server's per-session extension: emits a2a.inbound like an inbound A2A run would.</summary>
private sealed class InboundEmitter(int hops) : IExtension
{
public string Name => "inbound-emitter";
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
api.On<Volcanic.AgenticCore.Abstractions.Extensions.Events.BeforeAgentStartEvent>((_, _, _) =>
{
api.Events.Emit(A2AClientExtension.InboundChannel, VolcanicJson.ToElement(new { hops, caller = "upstream" }));
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}
}
[Fact]
public async Task Hops_Incremented_WithoutInbound()
{
await using var remote = await RemoteAgentServer.StartAsync(o => o.MaxHops = 1);
remote.Faux.Enqueue(FauxTurn.Say("fine"));
await using var host = new A2AClientHost(remote);
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "hi" });
Assert.Contains("completed", text); // sent with hops 1, allowed at MaxHops 1
}
[Fact]
public async Task Hops_FromInboundEvent()
{
await using var remote = await RemoteAgentServer.StartAsync(o => o.MaxHops = 3);
await using var host = new A2AClientHost(remote, null, new InboundEmitter(3));
var session = await host.CreateSessionAsync();
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "hi" });
Assert.Contains("failed", text);
Assert.Contains("hop limit", text);
Assert.Empty(remote.Faux.Requests);
}
}
(BeforeAgentStartEvent runs at the start of every run, before the model call that triggers the tool, so the hop count is set by then. If its namespace differs, use AgentStartEvent.)
-
Step 3: Run to see them fail — build error.
-
Step 4: Implement the tool
extensions/Volcanic.AgenticCore.Extensions.A2A/A2ATool.cs:
using System.ComponentModel;
using System.Net;
using System.Text;
using System.Text.Json;
using A2A;
using Volcanic.AgenticCore.Abstractions.Tools;
using A2AMessage = global::A2A.Message;
namespace Volcanic.AgenticCore.Extensions.A2A;
public sealed record A2AArgs(
[property: Description("\"agents\" (list remote agents), \"send\" (message an agent), \"status\" (check a task) or \"cancel\" (cancel a task)")] string Action,
[property: Description("Agent name from .volcanic/a2a/agents.json (send, status, cancel)")] string? Agent = null,
[property: Description("Text to send (send)")] string? Message = null,
[property: Description("Continue an earlier conversation with this agent: the context id from a previous result (send)")] string? ContextId = null,
[property: Description("A task id: answer the agent's question (send), or the task to check or cancel (status, cancel)")] string? TaskId = null,
[property: Description("Workspace files to attach (send)")] string[]? Files = null);
/// <summary>The <c>a2a</c> tool: remote agents from <c>agents.json</c>. Errors are thrown with readable, key-free messages.</summary>
internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatchRegistry registry, Func<int> inboundHops) : Tool<A2AArgs>
{
public override string Name => "a2a";
public override string Description =>
"Talk to other AI agents over the A2A protocol. Agents are configured in .volcanic/a2a/agents.json " +
"([{\"name\", \"url\", \"key\"}]). Use action=agents to see them and their skills, action=send to delegate work.";
public override string? PromptSnippet => "a2a: delegate to other AI agents listed in .volcanic/a2a/agents.json";
public override IReadOnlyList<string> PromptGuidelines =>
[
"When a remote agent asks a question (input-required), answer with action=send and its taskId; ask the user first if you don't know the answer.",
"Long remote tasks are watched for you: their result arrives later as a message starting with [A2A result from …].",
];
protected override async Task<ToolResult> ExecuteAsync(A2AArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
{
var workspace = invocation.Context.Cwd;
return args.Action?.Trim().ToLowerInvariant() switch
{
"agents" => ToolResult.Text(await ListAsync(workspace, cancellationToken).ConfigureAwait(false)),
"send" => ToolResult.Text(await SendAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
"status" => ToolResult.Text(await StatusAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
"cancel" => ToolResult.Text(await CancelAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
_ => throw new ArgumentException("action must be agents, send, status or cancel."),
};
}
private async Task<string> ListAsync(string workspace, CancellationToken ct)
{
var file = AgentsFile.Read(workspace);
if (file.Error is not null) return file.Error;
if (file.Agents.Count == 0)
return "No remote agents configured. Add them to .volcanic/a2a/agents.json: [{\"name\": \"travel-bot\", \"url\": \"https://…\", \"key\": \"…\"}]";
var sb = new StringBuilder("Remote agents:");
foreach (var entry in file.Agents)
{
try
{
using var agent = await registry.Connector(settings).ConnectAsync(entry, ct).ConfigureAwait(false);
var card = agent.Card;
sb.Append($"\n- {entry.Name}: {entry.Description ?? card.Description} ({card.Name})");
var caps = new List<string>();
if (agent.Streaming) caps.Add("streaming");
if (agent.SupportsPush) caps.Add("push");
if (caps.Count > 0) sb.Append($" [{string.Join(", ", caps)}]");
foreach (var skill in card.Skills ?? [])
{
sb.Append($"\n - skill {skill.Id}: {skill.Name} — {skill.Description}");
if (skill.Examples is { Count: > 0 } examples) sb.Append($" (e.g. {string.Join("; ", examples.Take(3))})");
}
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
sb.Append($"\n- {entry.Name}: unreachable ({Clean(ex.Message, entry)})");
}
}
foreach (var problem in file.Problems) sb.Append($"\n(skipped: {problem})");
return sb.ToString();
}
private async Task<string> SendAsync(string workspace, A2AArgs args, CancellationToken ct)
{
var entry = Entry(workspace, args.Agent);
if (string.IsNullOrWhiteSpace(args.Message) && args.Files is not { Length: > 0 })
throw new ArgumentException("send needs a message or files.");
var parts = new List<Part>();
if (!string.IsNullOrWhiteSpace(args.Message)) parts.Add(Part.FromText(args.Message));
foreach (var path in args.Files ?? []) parts.Add(FilePart(workspace, path));
return await CallAsync(entry, async agent =>
{
var response = await agent.Client.SendMessageAsync(new SendMessageRequest
{
Message = new A2AMessage
{
Role = Role.User, MessageId = Guid.NewGuid().ToString("N"), Parts = parts,
ContextId = string.IsNullOrWhiteSpace(args.ContextId) ? null : args.ContextId,
TaskId = string.IsNullOrWhiteSpace(args.TaskId) ? null : args.TaskId,
},
Configuration = new SendMessageConfiguration { ReturnImmediately = true },
Metadata = new Dictionary<string, JsonElement> { ["volcanic.hops"] = JsonSerializer.SerializeToElement(inboundHops() + 1) },
}, ct).ConfigureAwait(false);
if (response.Task is not { } task)
{
var message = response.Message;
return $"Agent \"{entry.Name}\" replied (context {message?.ContextId}):\n{A2AResults.TextOf(message)}";
}
task = await WaitAsync(agent, task, ct).ConfigureAwait(false);
if (A2AResults.IsSettled(task.Status.State))
return A2AResults.Describe(entry.Name, task, A2AResults.SaveFiles(workspace, entry.Name, task, settings.MaxFileBytes));
await registry.StartAsync(target, workspace, agent, task, settings, ct).ConfigureAwait(false);
return A2AResults.Describe(entry.Name, task, []) +
"\nIt is still running: the result will be delivered to this conversation when it is ready. You can also check it with action=status.";
}).ConfigureAwait(false);
}
private async Task<string> StatusAsync(string workspace, A2AArgs args, CancellationToken ct)
{
var entry = Entry(workspace, args.Agent);
var taskId = Required(args.TaskId, "taskId");
return await CallAsync(entry, async agent =>
{
var task = await agent.Client.GetTaskAsync(new GetTaskRequest { Id = taskId }, ct).ConfigureAwait(false);
var files = A2AResults.IsSettled(task.Status.State) ? A2AResults.SaveFiles(workspace, entry.Name, task, settings.MaxFileBytes) : [];
return A2AResults.Describe(entry.Name, task, files);
}).ConfigureAwait(false);
}
private async Task<string> CancelAsync(string workspace, A2AArgs args, CancellationToken ct)
{
var entry = Entry(workspace, args.Agent);
var taskId = Required(args.TaskId, "taskId");
await registry.StopWatchingAsync(target, workspace, taskId).ConfigureAwait(false);
return await CallAsync(entry, async agent =>
{
var task = await agent.Client.CancelTaskAsync(new CancelTaskRequest { Id = taskId }, ct).ConfigureAwait(false);
return A2AResults.Describe(entry.Name, task, []);
}).ConfigureAwait(false);
}
/// <summary>Wait up to WaitSeconds for the task to settle: subscribe (streaming agents) or poll every second.</summary>
private async Task<AgentTask> WaitAsync(RemoteAgent agent, AgentTask task, CancellationToken ct)
{
if (A2AResults.IsSettled(task.Status.State) || settings.WaitSeconds <= 0) return task;
using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct);
wait.CancelAfter(TimeSpan.FromSeconds(settings.WaitSeconds));
try
{
if (agent.Streaming)
{
AgentTask? current = task;
await foreach (var ev in agent.Client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = task.Id }, wait.Token).ConfigureAwait(false))
{
current = TaskProjection.Apply(current, ev) ?? current;
if (A2AResults.IsSettled(current.Status.State)) break;
}
}
else
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(1), registry.Time, wait.Token).ConfigureAwait(false);
var latest = await agent.Client.GetTaskAsync(new GetTaskRequest { Id = task.Id }, wait.Token).ConfigureAwait(false);
if (A2AResults.IsSettled(latest.Status.State)) return latest;
}
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { } // waited long enough
catch (A2AException) { } // e.g. subscribe on a task that just finished
return await agent.Client.GetTaskAsync(new GetTaskRequest { Id = task.Id }, ct).ConfigureAwait(false);
}
private async Task<string> CallAsync(RemoteAgentEntry entry, Func<RemoteAgent, Task<string>> call)
{
try
{
using var agent = await registry.Connector(settings).ConnectAsync(entry, CancellationToken.None).ConfigureAwait(false);
return await call(agent).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
throw new InvalidOperationException($"Agent \"{entry.Name}\" rejected the key ({(int)ex.StatusCode!}). Check its key in .volcanic/a2a/agents.json.");
}
catch (HttpRequestException ex)
{
throw new InvalidOperationException($"Could not reach agent \"{entry.Name}\": {Clean(ex.Message, entry)}");
}
catch (A2AException ex)
{
throw new InvalidOperationException($"Agent \"{entry.Name}\" refused: {Clean(ex.Message, entry)} ({ex.ErrorCode}).");
}
}
private static RemoteAgentEntry Entry(string workspace, string? name)
{
var file = AgentsFile.Read(workspace);
if (file.Error is not null) throw new InvalidOperationException(file.Error);
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("agent is required.");
return file.Agents.FirstOrDefault(a => a.Name == name)
?? throw new ArgumentException($"Unknown agent \"{name}\". Configured: {(file.Agents.Count == 0 ? "none" : string.Join(", ", file.Agents.Select(a => a.Name)))}.");
}
private Part FilePart(string workspace, string path)
{
var root = Path.GetFullPath(workspace).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var full = Path.GetFullPath(Path.Combine(workspace, path));
if (!full.StartsWith(root, StringComparison.Ordinal)) throw new ArgumentException($"{path} is outside the workspace.");
var info = new FileInfo(full);
if (info.LinkTarget is not null && info.ResolveLinkTarget(true) is { } resolved && !resolved.FullName.StartsWith(root, StringComparison.Ordinal))
throw new ArgumentException($"{path} points outside the workspace.");
if (!info.Exists) throw new ArgumentException($"{path} does not exist.");
if (info.Length > settings.MaxFileBytes) throw new ArgumentException($"{path} is larger than {settings.MaxFileBytes} bytes.");
return Part.FromRaw(File.ReadAllBytes(full), MediaType(full), Path.GetFileName(full));
}
private static string MediaType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
{
".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".pdf" => "application/pdf", ".txt" => "text/plain",
".md" => "text/markdown", ".json" => "application/json", ".csv" => "text/csv", _ => "application/octet-stream",
};
private static string Required(string? value, string name) =>
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} is required.") : value;
/// <summary>Exception texts must never echo a key.</summary>
private static string Clean(string text, RemoteAgentEntry entry) =>
entry.Key is { Length: > 0 } key ? text.Replace(key, "[key]", StringComparison.Ordinal) : text;
}
(The tool's WaitAsync uses CancelAfter on real time; tests that need fake time only use WaitSeconds = 0.)
- Step 5: Implement the extension
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AClientExtension.cs:
using System.Text.Json;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>
/// Per session (the host adds it, e.g. per Telegram chat; never host-wide): the <c>a2a</c> tool bound to the host's
/// <paramref name="target"/>, the hop counter from <c>a2a.inbound</c>, and the live-session delivery hook that also
/// flushes results kept while the target had no live conversation.
/// </summary>
public sealed class A2AClientExtension(A2AClientSettings settings, string target, A2AWatchRegistry? registry = null) : IExtension
{
public const string ExtensionName = "a2a-client";
/// <summary>Emitted by the A2A server's per-session extension before each inbound prompt: <c>{"hops": n, "caller": "…"}</c>.</summary>
public const string InboundChannel = "a2a.inbound";
private readonly A2AWatchRegistry _registry = registry ?? A2AWatchRegistry.Shared;
private volatile int _hops;
public string Name => ExtensionName;
public ValueTask ActivateAsync(IExtensionApi api, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(api);
api.RegisterTool(new A2ATool(settings, target, _registry, () => _hops));
api.Events.On(InboundChannel, data =>
{
if (data is JsonElement { ValueKind: JsonValueKind.Object } e && e.TryGetProperty("hops", out var h) && h.TryGetInt32(out var n))
_hops = n;
});
api.On<SessionStartEvent>((_, ctx, _) =>
{
var workspace = ctx.Cwd;
_registry.Live.Attach(target, workspace, api);
// Not inline: delivering may start a run, which mustn't happen inside session creation.
_ = Task.Run(() => _registry.FlushUndeliveredAsync(target, workspace, CancellationToken.None));
return ValueTask.CompletedTask;
});
api.On<SessionShutdownEvent>((_, _, _) =>
{
_registry.Live.Detach(target, api);
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}
}
- Step 6: Run tests
Run: dotnet test tests/Volcanic.AgenticCore.Extensions.Tests --filter "FullyQualifiedName~A2A"
Expected: all A2A tests pass (13 tool/hops + earlier). Run 3 times for flakiness. If Send_SlowTask_* fails because the follow-up's SendUserMessage runs while the session is idle, check MessageDelivery.FollowUp semantics in IExtension.cs ("delivered when the agent has no more tool calls…"; idle → starts a run) — the core starts a run for follow-ups on an idle session.
- Step 7: Commit
git add extensions/Volcanic.AgenticCore.Extensions.A2A tests/Volcanic.AgenticCore.Extensions.Tests/A2A
git commit -m "feat(a2a-client): the a2a tool with agents, send, status and cancel, and per-session wiring"
Task 8: Telegram host
Files:
-
Create:
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatFollowUps.cs -
Modify:
hosts/Volcanic.AgenticCore.Telegram/Scheduling/TelegramJobDelivery.cs(useChatFollowUps) -
Create:
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2AResultDelivery.cs -
Create:
hosts/Volcanic.AgenticCore.Telegram/A2A/A2AWatchHostedService.cs -
Create:
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2AClientSetup.cs -
Modify:
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatRegistry.cs,Chats/ChatCommands.cs,Program.cs,appsettings.json,Volcanic.AgenticCore.Telegram.csproj -
Modify:
tests/Volcanic.AgenticCore.Telegram.Tests/Support/ChatHost.cs -
Test:
tests/Volcanic.AgenticCore.Telegram.Tests/A2AClientHostTests.cs -
Step 1: Failing tests
using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Telegram.A2A;
using Volcanic.AgenticCore.Telegram.Tests.Support;
using Volcanic.AgenticCore.Testing;
namespace Volcanic.AgenticCore.Telegram.Tests;
public class A2AClientHostTests
{
[Fact]
public async Task ClientDisabled_NoA2aTool()
{
await using var host = new ChatHost();
var chat = await host.ChatAsync();
Assert.DoesNotContain(chat.Session.Tools, t => t.Name == "a2a");
}
[Fact]
public async Task ClientEnabled_ChatHasA2aTool()
{
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
var chat = await host.ChatAsync();
Assert.Contains(chat.Session.Tools, t => t.Name == "a2a");
}
[Fact]
public async Task ResultDelivery_GoesToChatWithTimestampHeader()
{
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
host.Faux.Enqueue(FauxTurn.Say("Got the result"));
var delivery = new TelegramA2AResultDelivery(host.Chats, host.Settings, host.Options, TimeProvider.System, NullLogger<TelegramA2AResultDelivery>.Instance);
Assert.True(await delivery.DeliverAsync("1001", "[A2A result from \"remote\", task t1 (completed)]\nDone", CancellationToken.None));
var prompt = host.Faux.Requests.Last().Messages.Last().Text;
Assert.Contains("[A2A result from \"remote\"", prompt);
Assert.Matches(@"^\[.*\d{4}", prompt); // the local-time header comes first
Assert.Contains(host.Texts, t => t.Contains("Got the result"));
}
[Fact]
public async Task ResultDelivery_UnknownOrNotAllowedTarget_False()
{
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
var delivery = new TelegramA2AResultDelivery(host.Chats, host.Settings, host.Options, TimeProvider.System, NullLogger<TelegramA2AResultDelivery>.Instance);
Assert.False(await delivery.DeliverAsync("999", "x", CancellationToken.None));
Assert.False(await delivery.DeliverAsync("not-a-chat", "x", CancellationToken.None));
Assert.Equal(["1001"], delivery.KnownTargets());
}
[Fact]
public async Task A2aCommand_ListsAgentsAndWatches()
{
await using var host = new ChatHost(a2aClient: new A2AClientSettings { Enabled = true });
var ws = host.WorkspaceOf();
Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(ws))!);
File.WriteAllText(AgentsFile.PathFor(ws), """[ { "name": "travel-bot", "url": "https://travel.example/", "key": "agent-key-123456789" } ]""");
await host.SendAsync("/a2a");
await host.SettleAsync();
var reply = host.LastReply();
Assert.Contains("travel-bot", reply);
Assert.Contains("https://travel.example/", reply);
Assert.DoesNotContain("agent-key-123456789", reply);
Assert.Contains("Watches", reply);
}
[Fact]
public async Task A2aCommand_BothOff_SaysHowToEnable()
{
await using var host = new ChatHost();
await host.SendAsync("/a2a");
await host.SettleAsync();
var reply = host.LastReply();
Assert.Contains("A2A is off", reply);
Assert.Contains("A2AClient__Enabled", reply);
}
}
-
Step 2: Run to see them fail — build error (
a2aClient:parameter,TelegramA2AResultDelivery). -
Step 3: Extract
ChatFollowUps
hosts/Volcanic.AgenticCore.Telegram/Chats/ChatFollowUps.cs:
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Telegram.Inbound;
namespace Volcanic.AgenticCore.Telegram.Chats;
/// <summary>
/// Runs a prompt in a chat's session as a follow-up with the local-time header, never through the inbox (so it is never
/// debounced into a user's message). Idle: returns when the run settles. Busy: queued, returns when the run that took it
/// settles.
/// </summary>
internal static class ChatFollowUps
{
public static async Task RunAsync(ChatContext chat, string prompt, TimeZoneInfo zone, TimeProvider time, CancellationToken cancellationToken)
{
var text = MessageContext.TimestampHeader(time.GetUtcNow(), zone) + "\n" + prompt;
var queued = chat.Session.IsStreaming;
await chat.Session.PromptAsync(text, null, StreamingBehavior.FollowUp, cancellationToken).ConfigureAwait(false);
if (queued) await chat.Session.WaitForIdleAsync(cancellationToken).ConfigureAwait(false);
}
}
In TelegramJobDelivery.RunPromptAsync, replace the three lines that build text, check queued, call PromptAsync and WaitForIdleAsync with await ChatFollowUps.RunAsync(chat, prompt, TimeZoneFor(target), time, cancellationToken).ConfigureAwait(false); (keep its try/catch and comment). Run the scheduler chat tests: dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~Scheduled" — still green.
- Step 4: Delivery, hosted service, setup
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2AResultDelivery.cs:
using System.Globalization;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.A2A;
using Volcanic.AgenticCore.Telegram.Chats;
namespace Volcanic.AgenticCore.Telegram.A2A;
/// <summary>A2A watch results as follow-up prompts in the chat (target = chat id), like scheduled prompts.</summary>
public sealed class TelegramA2AResultDelivery(ChatRegistry chats, ChatSettingsStore settings, TelegramOptions options, TimeProvider time,
ILogger<TelegramA2AResultDelivery> logger) : IA2AResultDelivery
{
public async Task<bool> DeliverAsync(string target, string text, CancellationToken cancellationToken)
{
if (!long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) || !options.IsAllowed(chatId))
return false;
var chat = await chats.GetAsync(chatId, cancellationToken).ConfigureAwait(false);
try
{
await ChatFollowUps.RunAsync(chat, text, settings.TimeZoneFor(chatId), time, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "A2A result in chat {ChatId} failed", chatId);
chat.Report($"An A2A result arrived but couldn't be processed: {ex.Message}");
}
return true;
}
public string? WorkspaceFor(string target) =>
long.TryParse(target, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var chatId) ? ChatRegistry.WorkspaceFor(options, chatId) : null;
public IEnumerable<string> KnownTargets() => options.AllowedUserIds.Select(id => id.ToString(CultureInfo.InvariantCulture)).ToList();
}
hosts/Volcanic.AgenticCore.Telegram/A2A/A2AWatchHostedService.cs:
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Telegram.A2A;
/// <summary>Points the watch registry at the chats and re-arms persisted watches; stops them on shutdown.</summary>
public sealed class A2AWatchHostedService(A2AWatchRegistry registry, A2AClientSettings settings, TelegramA2AResultDelivery delivery,
ILogger<A2AWatchHostedService> logger) : IHostedService
{
public async Task StartAsync(CancellationToken cancellationToken)
{
registry.Logger = logger;
registry.UseDelivery(delivery);
var count = await registry.RestoreAsync(settings, cancellationToken).ConfigureAwait(false);
logger.LogInformation("A2A client: {Count} watch(es) restored", count);
}
public Task StopAsync(CancellationToken cancellationToken) => registry.StopAsync();
}
hosts/Volcanic.AgenticCore.Telegram/A2A/TelegramA2AClientSetup.cs:
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.A2A.Server;
using Volcanic.AgenticCore.Extensions.A2A;
namespace Volcanic.AgenticCore.Telegram.A2A;
/// <summary>
/// The A2A client (off unless <c>A2AClient__Enabled=true</c>). Call before <see cref="TelegramA2ASetup.Register"/>: when the
/// server is on, its push receiver is routed into the watch registry and its public URL becomes the push base URL.
/// </summary>
public static class TelegramA2AClientSetup
{
public static void Register(IServiceCollection services, A2AClientSettings client, A2AHostOptions server, A2AWatchRegistry registry)
{
services.AddSingleton(client);
if (!client.Enabled) return;
client.PushBaseUrl = server.Enabled ? server.PublicUrl : null;
if (server.Enabled) server.OnPush = registry.OnPushAsync;
services.AddSingleton(registry);
services.AddSingleton<TelegramA2AResultDelivery>();
services.AddHostedService<A2AWatchHostedService>();
}
public static string Describe(A2AClientSettings client) =>
client.Enabled
? $"A2A client: on, agents in each chat's .volcanic/a2a/agents.json, {(client.PushBaseUrl is null ? "watches subscribe or poll" : "push at " + client.PushBaseUrl)}"
: "A2A client: off";
}
Add <ProjectReference Include="../../extensions/Volcanic.AgenticCore.Extensions.A2A/Volcanic.AgenticCore.Extensions.A2A.csproj" /> to the Telegram csproj.
- Step 5: ChatRegistry, ChatCommands, Program, appsettings
ChatRegistry: add constructor parameters A2AClientSettings? a2aClient = null, A2AWatchRegistry? a2aWatches = null (last), and in CreateAsync after the triggers extra:
if (a2aClient is { Enabled: true })
extras.Add(ExtensionRegistration.FromInstance(new A2AClientExtension(a2aClient, Scheduling.TelegramJobDelivery.TargetOf(chatId), a2aWatches)));
ChatCommands: add constructor parameters A2AClientSettings? a2aClient = null, A2AWatchRegistry? a2aWatches = null (last). Replace A2AStatus with:
private string A2AStatus(ChatContext chat)
{
var serverOn = a2a?.Value is { Enabled: true };
var clientOn = a2aClient is { Enabled: true };
if (!serverOn && !clientOn)
return "A2A is off. Set A2A__Enabled=true (and A2A__PublicUrl) to let other agents talk to this assistant, " +
"A2AClient__Enabled=true to let it talk to other agents.";
var workspace = chat.Session.Cwd;
var lines = new List<string>();
if (serverOn)
{
var callers = CallersFile.Read(workspace, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
lines.Add("**A2A callers** (.volcanic/a2a/callers.json)");
lines.AddRange(callers.Count == 0 ? ["none"] : callers.Select(c => $"• {c.Name} ({c.Mode}) key {Mask(c.Key)}"));
var tasks = A2ATaskFiles.Recent(workspace, 10);
lines.Add("**Recent A2A tasks**");
lines.AddRange(tasks.Count == 0 ? ["none"] : tasks.Select(t => $"• {t.Caller}: {t.State}{Age(t.Updated)} ({t.TaskId[..Math.Min(8, t.TaskId.Length)]})"));
}
else lines.Add("A2A server: off (A2A__Enabled).");
if (clientOn)
{
var agents = AgentsFile.Read(workspace);
lines.Add("**Remote agents** (.volcanic/a2a/agents.json)");
if (agents.Error is not null) lines.Add(agents.Error);
lines.AddRange(agents.Agents.Count == 0 ? ["none"] : agents.Agents.Select(a => $"• {a.Name}: {a.Url}{(a.Key is null ? "" : " key " + Mask(a.Key))}"));
var watches = a2aWatches?.List(Scheduling.TelegramJobDelivery.TargetOf(chat.ChatId)) ?? [];
lines.Add("**Watches**");
lines.AddRange(watches.Count == 0 ? ["none"] : watches.Select(w => $"• {w.Agent}: task {w.TaskId[..Math.Min(8, w.TaskId.Length)]} by {w.Source}{Age(w.StartedAt)}"));
}
else lines.Add("A2A client: off (A2AClient__Enabled).");
return string.Join('\n', lines);
}
private static string Mask(string key) => key.Length >= 12 ? "…" + key[^4..] : "…";
(Add using Volcanic.AgenticCore.Extensions.A2A;. Keep the existing Age helper.)
Program.cs: after reading a2a (server options):
var a2aClient = builder.Configuration.GetSection(A2AClientSettings.SectionName).Get<A2AClientSettings>() ?? new A2AClientSettings();
and before TelegramA2ASetup.Register(...):
TelegramA2AClientSetup.Register(builder.Services, a2aClient, a2a, A2AWatchRegistry.Shared);
Pass the client into ChatRegistry and ChatCommands: both are DI-constructed with optional parameters, and A2AClientSettings is always registered; A2AWatchRegistry only when enabled — so they resolve on their own. Log TelegramA2AClientSetup.Describe(a2aClient) next to the A2A server line.
appsettings.json: add "A2AClient": { "Enabled": false, "WaitSeconds": 60, "WatchTimeout": "1.00:00:00", "CardTimeoutSeconds": 10, "MaxFileBytes": 10485760 }.
ChatHost (tests): add parameter A2AClientSettings? a2aClient = null; when given, create A2AWatchRegistry = new A2AWatchRegistry(clock, new HttpClientHandler()) (expose as a property) and pass both to ChatRegistry and ChatCommands; dispose the registry in DisposeAsync.
- Step 6: Run tests
Run: dotnet build && dotnet test tests/Volcanic.AgenticCore.Telegram.Tests
Expected: 0 warnings, all green (6 new). The existing A2aCommand_DisabledReply still expects "A2A is off" — still true.
- Step 7: Commit
git add hosts/Volcanic.AgenticCore.Telegram tests/Volcanic.AgenticCore.Telegram.Tests
git commit -m "feat(telegram): A2A client per chat behind A2AClient__Enabled, watch results as follow-ups, /a2a lists agents"
Task 9: Compose, docs, spec
Files:
-
Modify:
docker-compose.yml,docs/a2a.md,docs/extensions.md,docs/telegram-bot.md,docs/docker.md,CLAUDE.md,docs/superpowers/specs/2026-10-07-a2a-client-design.md -
Step 1: Compose — under
environment:add- A2AClient__Enabled=${A2A_CLIENT_ENABLED:-false} # A2A client: the a2a tool and watches (a2a-client spec). Run the compose tests:dotnet test tests/Volcanic.AgenticCore.Telegram.Tests --filter "FullyQualifiedName~DockerCompose". -
Step 2: Docs — describe what the code does (read it):
docs/a2a.md: a "Client" part: enabling (A2AClient__Enabled, independent of the server; only the owner's chats get the tool, not inbound A2A sessions),agents.json(example, rules, read per call, keys never shown), thea2atool actions and results (context/task ids, input-required answering, files in/out underinbox/a2a/<agent>/), watches (push only when the server is on withPublicUrl; else subscribe/poll with backoff;WatchTimeout;pending.json; restart restore; delivery as a follow-up with the time header; undelivered results), hops, config table, security notes (agents.json keys are plaintext in the workspace; the agent can edit agents.json and therefore choose where it sends data — same trust as its bash access), a Volcanic↔Volcanic example (two workspaces, callers.json on one side, agents.json on the other).docs/extensions.md: list the A2A extension with one paragraph.docs/telegram-bot.md:A2AClient__Enabledin the config table,/a2aadditions.docs/docker.md:A2A_CLIENT_ENABLED.CLAUDE.md: add the extension to the Extensions paragraph; mention the client in the TelegramA2A/sentence.- Spec: Status → implemented; record deviations if any arose.
-
Step 3: Full verification
Run: dotnet build && dotnet test
Expected: 0 warnings; every project green.
- Step 4: Commit
git add docker-compose.yml docs CLAUDE.md
git commit -m "docs(a2a): A2A client guide, configuration and Telegram notes"