feat(a2a-client): the a2a tool with agents, send, status and cancel, and per-session wiring

This commit is contained in:
EugeneTes
2026-10-08 13:49:06 +00:00
parent d7f5e0b900
commit 173b7dfc18
5 changed files with 760 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
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<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);
}
}

View File

@@ -0,0 +1,252 @@
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;
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 15_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
await Task.Delay(20);
}
}
[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", "tags": ["travel"], "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.Contains("Find a flight", 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/missing/card.json", "key": "ghost-key-0123456789" } ]""");
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "agents" });
Assert.False(isError);
Assert.Contains("ghost", text);
Assert.Contains("unreachable", text);
Assert.DoesNotContain("ghost-key-0123456789", 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"));
Assert.Empty(host.Registry.List(A2AClientHost.Target));
}
[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" });
var (second, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "two", contextId = Field(first, "context") });
Assert.Contains("second", second);
Assert.Equal(Field(first, "context"), Field(second, "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();
// The follow-up run gets the faux default answer.
var (text, _) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "take your time" });
Assert.True(text.Contains("working") || text.Contains("submitted"), text);
Assert.Contains("delivered", text);
Assert.Single(host.Registry.List(A2AClientHost.Target));
var delivered = await host.WaitForUserMessageAsync(session, "[A2A result from \"remote\"");
Assert.Contains("Slow answer", delivered);
Assert.Contains(host.Faux.Requests, r => r.Messages.Any(m => m.Text.Contains("Slow answer")));
Assert.Empty(host.Registry.List(A2AClientHost.Target));
await WaitUntilAsync(() => PendingFile.Read(host.Workspace).Count == 0); // removed right after the hand-over
}
[Fact]
public async Task SessionStart_FlushesUndeliveredResult_AsFollowUp()
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
PendingFile.Upsert(host.Workspace, new WatchRecord("w1", A2AClientHost.Target, "remote", "t1", "c1", null, DateTimeOffset.UtcNow,
WatchSources.Poll, "[A2A result from \"remote\", task t1 (completed)]\nKept for later"));
host.Faux.Enqueue(FauxTurn.Say("got it"));
var session = await host.CreateSessionAsync(); // returns before the flush's run (it runs off the creation path)
var delivered = await host.WaitForUserMessageAsync(session, "Kept for later");
Assert.StartsWith("[A2A result from \"remote\"", delivered);
await WaitUntilAsync(() => PendingFile.Read(host.Workspace).Count == 0);
}
[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, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "here", files = new[] { "notes.txt" } });
Assert.False(isError, text);
Assert.Contains(remote.Faux.Requests.First().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")));
var inbound = Assert.Single(Directory.GetFiles(Path.Combine(remote.Workspace, "inbox"), "a2a-client-*"));
Assert.Equal("my notes", File.ReadAllText(inbound));
}
[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);
Assert.Empty(remote.Faux.Requests);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Send_SymlinkOutsideWorkspace_Error(bool directoryLink)
{
await using var remote = await RemoteAgentServer.StartAsync();
await using var host = new A2AClientHost(remote);
var outside = Path.Combine(Path.GetTempPath(), "a2a-outside", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outside);
try
{
File.WriteAllText(Path.Combine(outside, "secret.txt"), "secret");
string path;
if (directoryLink)
{
Directory.CreateSymbolicLink(Path.Combine(host.Workspace, "linked"), outside);
path = "linked/secret.txt";
}
else
{
File.CreateSymbolicLink(Path.Combine(host.Workspace, "secret.txt"), Path.Combine(outside, "secret.txt"));
path = "secret.txt";
}
var session = await host.CreateSessionAsync();
var (text, isError) = await host.CallToolAsync(session, new { action = "send", agent = "remote", message = "x", files = new[] { path } });
Assert.True(isError);
Assert.Contains("outside the workspace", text);
Assert.Empty(remote.Faux.Requests);
}
finally
{
Directory.Delete(outside, true);
}
}
[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");
Assert.Single(host.Registry.List(A2AClientHost.Target));
var (status, _) = await host.CallToolAsync(session, new { action = "status", agent = "remote", taskId });
Assert.Contains("working", status);
var (cancelled, isError) = await host.CallToolAsync(session, new { action = "cancel", agent = "remote", taskId });
Assert.False(isError, cancelled);
Assert.Contains("canceled", cancelled);
Assert.Empty(host.Registry.List(A2AClientHost.Target));
Assert.Empty(PendingFile.Read(host.Workspace));
Assert.DoesNotContain(host.UserTexts(), t => t.Contains("[A2A result", StringComparison.Ordinal));
}
[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, isError) = await host.CallToolAsync(session, new { action = "agents" });
Assert.False(isError);
Assert.Contains(".volcanic/a2a/agents.json", text);
}
}

View File

@@ -0,0 +1,108 @@
using Microsoft.Extensions.DependencyInjection;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Messages;
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);
}
/// <summary>Texts of the user messages the sessions ran so far (prompts and follow-ups), from the session events.</summary>
public IReadOnlyList<string> UserTexts()
{
lock (Events)
return Events.OfType<AgentSessionEvent.MessageEnd>().Where(e => e.Message is UserMessage).Select(e => e.Message.Text).ToList();
}
/// <summary>Waits until a user message containing <paramref name="text"/> ran, then until that run settled.</summary>
public async Task<string> WaitForUserMessageAsync(AgentSession session, string text, int timeoutMs = 15_000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (true)
{
if (UserTexts().FirstOrDefault(t => t.Contains(text, StringComparison.Ordinal)) is { } found)
{
await session.WaitForIdleAsync();
return found;
}
if (DateTime.UtcNow > deadline) throw new TimeoutException($"no user message containing \"{text}\"");
await Task.Delay(20);
}
}
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) { }
}
}