Files
EugeneTes a92623b89b fix(a2a-client): save every file the remote agent returns, per task, with size and type
File parts were only taken from artifacts as inline bytes up to 10 MB; parts in the
status message or the history were dropped silently, URL parts were never downloaded,
and a plain message reply lost its files. Files now go to inbox/a2a/<taskId>/ from
artifacts, status message and recent agent messages (deduplicated), URLs are streamed
to disk (agent key only on the agent's origin, else https without it), the cap is the
new MaxReceivedFileBytes (200 MB), and results list path, bytes and media type or why
a file was not saved. Re-saving a task overwrites, so action=status refetches files.
2026-10-12 16:16:14 +00:00

98 lines
4.6 KiB
C#

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, 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>The tool result for a task.</summary>
public static string Describe(string agent, AgentTask task, IReadOnlyList<ReceivedFile> 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<ReceivedFile> 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<ReceivedFile> files)
{
var waiting = task.Status.State is TaskState.InputRequired or TaskState.AuthRequired;
// Waiting for us: the question (status message) leads; artifacts may be stale output of earlier turns.
var text = waiting ? WaitingText(task) : FinalText(task);
if (text is not null) sb.Append('\n').Append(text);
AppendFiles(sb, files);
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? WaitingText(AgentTask task)
{
var question = TextOf(task.Status.Message);
var rest = string.Join("\n", (task.Artifacts ?? []).Select(a => TextOf(a.Parts)).Where(t => t.Length > 0));
var all = string.Join("\n", new[] { question, rest }.Where(t => t.Length > 0));
return all.Length > 0 ? all : null;
}
/// <summary>One line per file: where it was saved, its size and media type, or why it wasn't.</summary>
public static void AppendFiles(StringBuilder sb, IReadOnlyList<ReceivedFile> files)
{
if (files.Count == 0) return;
sb.Append("\nFiles:");
foreach (var file in files)
{
var details = string.Join(", ", new[] { file.Bytes is { } b ? $"{b} bytes" : null, file.MediaType }.Where(d => d is not null));
sb.Append(file.Path is { } path
? $"\n- {path}{(details.Length > 0 ? $" ({details})" : "")}"
: $"\n- {file.Name}{(details.Length > 0 ? $" ({details})" : "")}: not saved, {file.Problem}{(file.Url is { } url ? $" ({url})" : "")}");
}
}
}