feat(a2a-client): describe remote tasks and save their files to the inbox
This commit is contained in:
111
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs
Normal file
111
extensions/Volcanic.AgenticCore.Extensions.A2A/A2AResults.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) { } }
|
||||
}
|
||||
Reference in New Issue
Block a user