From fcc0191319c23a77c184527554727a8dbf3a0130 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Thu, 8 Oct 2026 12:36:12 +0000 Subject: [PATCH] feat(a2a-client): describe remote tasks and save their files to the inbox --- .../A2AResults.cs | 111 ++++++++++++++++++ .../A2A/A2AResultsTests.cs | 75 ++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs create mode 100644 tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs diff --git a/extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs b/extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs new file mode 100644 index 0000000..6437332 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs @@ -0,0 +1,111 @@ +using System.Text; +using A2A; +using A2AMessage = global::A2A.Message; + +namespace Volcanic.AgenticCore.Extensions.A2A; + +/// How remote tasks read for the model: state names, the answer text, saved files, result and delivery texts. +internal static class A2AResults +{ + public const string ResponseArtifactId = "response"; + + /// Terminal, or waiting for us (input/auth required): nothing more happens without us. + 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? parts) => + string.Concat((parts ?? []).Where(p => p.ContentCase == PartContentCase.Text).Select(p => p.Text)); + + public static string TextOf(A2AMessage? message) => TextOf(message?.Parts); + + /// The answer: artifact response, else every text artifact part, else the status message. + 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; + } + + /// Saves file parts (inline bytes) under inbox/a2a/<agent>/; returns workspace-relative paths. + public static IReadOnlyList SaveFiles(string workspace, string agent, AgentTask task, long maxBytes) + { + var saved = new List(); + 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; + } + + /// Links the remote agent returned instead of bytes (not downloaded). + public static IReadOnlyList 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(); + + /// The tool result for a task. + public static string Describe(string agent, AgentTask task, IReadOnlyList 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(); + } + + /// The follow-up prompt a watch delivers. + public static string DeliveryText(string agent, AgentTask task, IReadOnlyList 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 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; + } +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs new file mode 100644 index 0000000..1279892 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/A2AResultsTests.cs @@ -0,0 +1,75 @@ +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) { } } +}