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.
This commit is contained in:
EugeneTes
2026-10-12 16:16:14 +00:00
parent 0cae6f600a
commit a92623b89b
11 changed files with 418 additions and 108 deletions

View File

@@ -271,7 +271,7 @@ does). Then:
- **Settled** (`completed`, `failed`, `canceled`, `rejected`, or waiting for us: `input-required`, `auth-required`):
the result is `Agent "travel-bot": task <taskId> (context <contextId>) is completed.`, then the answer, then
optional lines `Files: ...` (saved files), `Files skipped (over N bytes): ...` and `Links: ...`. The answer is
an optional `Files:` list (see "Files out" below). The answer is
the `response` artifact, else every text artifact, else the status message. For `input-required`/`auth-required`
the question (status message) comes first, followed by the hint to reply with `action=send`, the agent and
`taskId=<id>`; the model asks the user first when it doesn't know the answer.
@@ -285,9 +285,24 @@ after resolving every symlink; directories and missing files are refused, and al
`MaxFileBytes`. The media type comes from the extension (`.png`, `.jpg`, `.pdf`, `.txt`, `.md`, `.json`, `.csv`,
else `application/octet-stream`).
Files out: file parts with inline bytes in a settled task's artifacts are saved to `<workspace>/inbox/a2a/<agent>/`
(name from the part, sanitised; `-2`, `-3`, ... on a clash) and listed under `Files:`; a part over `MaxFileBytes` is
skipped and listed. URL parts are listed under `Links:` and not downloaded.
Files out: every file part of the answer is saved to `<workspace>/inbox/a2a/<taskId>/` (for a plain message reply,
`<messageId>`), whether it sits in an artifact, in the status message or in an agent message after our last one in
the task history. Inline bytes are written; URL parts are downloaded, streamed, with the agent's key only when the URL
is on the agent's own origin (its `agents.json` URL or a card interface), and otherwise only over `https` and without
the key. Each file may be at most `MaxReceivedFileBytes` (200 MB); a failed or partial download leaves nothing behind.
Names come from the part (else the URL's last segment, else the artifact), sanitised, with `-2`, `-3`, ... when two
parts of one answer share a name; the same part found twice (say, artifact and status message) is saved once. The
result lists each file with its size and media type, or why it was not saved:
```
Files:
- inbox/a2a/eff0e973.../SKILL.md (5222 bytes, text/markdown)
- crm.tar.gz (320000000 bytes, application/gzip): not saved, over 209715200 bytes
```
Saving a task again overwrites its folder's files, so `action=status` with the `taskId` of a finished task fetches
its files again (as long as the remote agent still returns them). Watch deliveries (`[A2A result from …]`) carry the
same list.
**cancel** stops the watch first, so a `canceled` outcome isn't delivered again later. If the remote refuses (typically
because the task already finished), the result says so and carries the task's outcome. If the remote can't be reached
@@ -329,7 +344,8 @@ The delivered text is a follow-up prompt:
[A2A result from "travel-bot", task 5c1e... (completed)]
Context: 7f20...
<the agent's answer>
Files: inbox/a2a/travel-bot/itinerary.pdf
Files:
- inbox/a2a/5c1e.../itinerary.pdf (48213 bytes, application/pdf)
```
`[A2A question from ...]` for `input-required`/`auth-required`, with the hint to answer with `send` and the `taskId`.
@@ -374,7 +390,8 @@ sessions that also receive A2A.
| `WaitSeconds` | `60` | How long `send` waits for a result before a watch takes over. `0` hands over at once |
| `WatchTimeout` | `1.00:00:00` | When a watch gives up, counted from its start |
| `CardTimeoutSeconds` | `10` | Card (and extended card) fetch timeout per agent |
| `MaxFileBytes` | `10485760` (10 MB) | Cap on the files attached to one `send` together, and on each received file part |
| `MaxFileBytes` | `10485760` (10 MB) | Cap on the files attached to one `send` together |
| `MaxReceivedFileBytes` | `209715200` (200 MB) | Cap per file the remote agent sends back (inline or downloaded) |
The push base URL isn't a client setting: the Telegram host sets it from `A2A:PublicUrl` while the server is on and
ignores any configured value otherwise.

View File

@@ -72,7 +72,7 @@ settings use the `GoogleCalendar` section, the scheduler the `Scheduler` section
| `A2A__Enabled` / `A2A:Enabled` | `false` | Serve other agents over A2A (see [A2A](#a2a) and [a2a.md](a2a.md)). Opens Kestrel: port 8080 in the container, `ASPNETCORE_URLS` or Kestrel's default (`http://localhost:5000`) under `dotnet run` |
| `A2A__PublicUrl` / `A2A:PublicUrl` | — | External base URL for the agent card and push webhooks, e.g. `https://bot.example.com`. Required for push to work; see [docker.md](docker.md) |
| `A2AClient__Enabled` / `A2AClient:Enabled` | `false` | The A2A client: the `a2a` tool in every allowed chat, for calling other agents listed in the chat's `.volcanic/a2a/agents.json`, and watches for their long tasks (see [A2A](#a2a) and [a2a.md](a2a.md#client)). Independent of `A2A__Enabled` |
| `A2AClient__*` | see [a2a.md](a2a.md#client-configuration) | `WaitSeconds`, `WatchTimeout`, `CardTimeoutSeconds`, `MaxFileBytes` |
| `A2AClient__*` | see [a2a.md](a2a.md#client-configuration) | `WaitSeconds`, `WatchTimeout`, `CardTimeoutSeconds`, `MaxFileBytes`, `MaxReceivedFileBytes` |
| `A2A__*` | see [a2a.md](a2a.md#configuration) | `MaxHops`, `SessionIdleTimeout`, `MaxFileBytes`, `AllowPrivatePushUrls`, `Card:Name`, `Card:Description` |
Sessions are stored as usual under `~/.volcanic/agent/sessions/telegram-<chatId>/`; after a restart each chat resumes

View File

@@ -15,6 +15,9 @@ public sealed class A2AClientSettings
public int CardTimeoutSeconds { get; set; } = 10;
public long MaxFileBytes { get; set; } = 10 * 1024 * 1024;
/// <summary>Cap per file the remote agent sends back (inline bytes or a downloaded link).</summary>
public long MaxReceivedFileBytes { get; set; } = 200 * 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.

View File

@@ -0,0 +1,186 @@
using System.Net.Http.Headers;
using A2A;
using A2AMessage = global::A2A.Message;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>
/// A file the remote agent sent: saved (<see cref="Path"/>, workspace-relative) or not (<see cref="Problem"/>, e.g. over
/// the size limit or a failed download, with the <see cref="Url"/> when there was one).
/// </summary>
internal sealed record ReceivedFile(string Name, long? Bytes, string? MediaType, string? Path, string? Problem = null, string? Url = null);
/// <summary>
/// Saves the file parts of a remote answer under <c>inbox/a2a/&lt;taskId&gt;/</c>: inline bytes are written, URLs are
/// downloaded (streamed, capped by <see cref="A2AClientSettings.MaxReceivedFileBytes"/>). Parts come from the artifacts,
/// the status message and the agent's messages after our last one in the history; the same part found twice is saved
/// once. Saving the same task again (status after a watch) overwrites its files, so it is idempotent.
/// </summary>
internal static class A2AFiles
{
public static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(5);
public static Task<IReadOnlyList<ReceivedFile>> SaveAsync(string workspace, AgentTask task, RemoteAgent? agent,
HttpMessageHandler handler, A2AClientSettings settings, CancellationToken ct) =>
SaveAsync(workspace, task.Id, PartsOf(task), agent, handler, settings, ct);
public static Task<IReadOnlyList<ReceivedFile>> SaveAsync(string workspace, A2AMessage message, RemoteAgent? agent,
HttpMessageHandler handler, A2AClientSettings settings, CancellationToken ct) =>
SaveAsync(workspace, message.TaskId ?? message.MessageId, (message.Parts ?? []).Select(p => (p, (string?)null)), agent, handler, settings, ct);
/// <summary>File parts with a fallback name (the artifact's), in order: artifacts, status message, recent agent messages.</summary>
internal static IEnumerable<(Part Part, string? Fallback)> PartsOf(AgentTask task)
{
foreach (var artifact in task.Artifacts ?? [])
foreach (var part in artifact.Parts ?? [])
yield return (part, artifact.Name ?? artifact.ArtifactId);
foreach (var part in task.Status.Message?.Parts ?? [])
yield return (part, null);
var history = task.History ?? [];
var lastOurs = history.FindLastIndex(m => m.Role == Role.User);
foreach (var message in history.Skip(lastOurs + 1).Where(m => m.Role == Role.Agent))
foreach (var part in message.Parts ?? [])
yield return (part, null);
}
private static async Task<IReadOnlyList<ReceivedFile>> SaveAsync(string workspace, string? folderId,
IEnumerable<(Part Part, string? Fallback)> parts, RemoteAgent? agent, HttpMessageHandler handler,
A2AClientSettings settings, CancellationToken ct)
{
var files = new List<ReceivedFile>();
var seen = new HashSet<string>();
var names = new HashSet<string>(StringComparer.Ordinal);
var folder = $"inbox/a2a/{Sanitize(folderId)}";
foreach (var (part, fallback) in parts)
{
if (part.ContentCase is not (PartContentCase.Raw or PartContentCase.Url)) continue;
var name = Sanitize(part.Filename ?? (part.Url is { } u ? UrlName(u) : null) ?? fallback);
var mediaType = string.IsNullOrWhiteSpace(part.MediaType) ? null : part.MediaType;
if (!seen.Add(part.Raw is { } raw ? $"raw:{name}:{raw.Length}" : $"url:{part.Url}")) continue;
var relative = UniqueName(folder, name, names);
var full = Path.Combine(workspace, relative);
if (part.Raw is { } bytes)
{
if (bytes.LongLength > settings.MaxReceivedFileBytes)
{
files.Add(new ReceivedFile(name, bytes.LongLength, mediaType, null, $"over {settings.MaxReceivedFileBytes} bytes"));
continue;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
await File.WriteAllBytesAsync(full, bytes, ct).ConfigureAwait(false);
files.Add(new ReceivedFile(name, bytes.LongLength, mediaType, relative));
}
// A file that can't be written must not cost the answer (or loop a watch): report it instead.
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
files.Add(new ReceivedFile(name, bytes.LongLength, mediaType, null, $"could not be written ({ex.GetType().Name})"));
}
}
else
{
files.Add(await DownloadAsync(part.Url!, name, mediaType, relative, full, agent, handler, settings, ct).ConfigureAwait(false));
}
}
return files;
}
/// <summary>
/// Downloads a URL part to <paramref name="full"/> through a temp file (a failed download leaves nothing behind).
/// The agent's key goes only to the agent's own origin; elsewhere only <c>https</c> is fetched, without the key.
/// </summary>
private static async Task<ReceivedFile> DownloadAsync(string url, string name, string? mediaType, string relative, string full,
RemoteAgent? agent, HttpMessageHandler handler, A2AClientSettings settings, CancellationToken ct)
{
ReceivedFile Failed(string problem, long? size = null) => new(name, size, mediaType, null, problem, url);
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http"))
return Failed("not an http(s) link, not downloaded");
var sameOrigin = agent is not null && SameOrigin(uri, agent);
if (!sameOrigin && uri.Scheme != "https") return Failed("not https, not downloaded");
var temp = full + ".part";
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(DownloadTimeout);
using var plain = sameOrigin ? null : new HttpClient(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan };
var http = sameOrigin ? agent!.Http : plain!;
using var response = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, timeout.Token).ConfigureAwait(false);
if (!response.IsSuccessStatusCode) return Failed($"download failed ({(int)response.StatusCode})");
if (response.Content.Headers.ContentLength is { } length && length > settings.MaxReceivedFileBytes)
return Failed($"over {settings.MaxReceivedFileBytes} bytes", length);
mediaType ??= response.Content.Headers.ContentType is MediaTypeHeaderValue { MediaType: { } type } ? type : null;
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
long total = 0;
await using (var source = await response.Content.ReadAsStreamAsync(timeout.Token).ConfigureAwait(false))
await using (var target = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous))
{
var buffer = new byte[81920];
int n;
while ((n = await source.ReadAsync(buffer, timeout.Token).ConfigureAwait(false)) > 0)
{
total += n;
if (total > settings.MaxReceivedFileBytes) break;
await target.WriteAsync(buffer.AsMemory(0, n), timeout.Token).ConfigureAwait(false);
}
}
if (total > settings.MaxReceivedFileBytes)
{
File.Delete(temp);
return Failed($"over {settings.MaxReceivedFileBytes} bytes");
}
File.Move(temp, full, overwrite: true);
return new ReceivedFile(name, total, mediaType, relative, null, url);
}
catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException ||
(ex is OperationCanceledException && !ct.IsCancellationRequested))
{
TryDelete(temp);
var reason = ex is OperationCanceledException ? $"no complete download within {DownloadTimeout}"
: agent is null ? ex.Message : agent.Entry.Scrub(ex.Message);
return Failed($"download failed: {reason}");
}
catch
{
TryDelete(temp);
throw;
}
}
private static bool SameOrigin(Uri uri, RemoteAgent agent)
{
var origins = new List<string?> { agent.Entry.Url };
origins.AddRange((agent.Card.SupportedInterfaces ?? []).Select(i => i.Url));
return origins.Any(o => Uri.TryCreate(o, UriKind.Absolute, out var a) &&
Uri.Compare(a, uri, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0);
}
private static void TryDelete(string path)
{
try { File.Delete(path); } catch (IOException) { } catch (UnauthorizedAccessException) { }
}
private static string UniqueName(string folder, string name, HashSet<string> taken)
{
var relative = $"{folder}/{name}";
for (var n = 2; !taken.Add(relative); n++)
relative = $"{folder}/{Path.GetFileNameWithoutExtension(name)}-{n}{Path.GetExtension(name)}";
return relative;
}
private static string? UrlName(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var uri) && Uri.UnescapeDataString(uri.Segments.LastOrDefault() ?? "").Trim('/') is { Length: > 0 } last
? last : null;
internal static string Sanitize(string? name)
{
var file = Path.GetFileName(name ?? "");
if (string.IsNullOrWhiteSpace(file) || file is "." or "..") return "file";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(file.Select(c => invalid.Contains(c) || char.IsWhiteSpace(c) ? '_' : c).Where(c => !char.IsControl(c)).ToArray());
if (clean.Length == 0 || clean is "." or "..") return "file";
return clean.Length > 80 ? clean[^80..] : clean;
}
}

View File

@@ -4,13 +4,7 @@ using A2AMessage = global::A2A.Message;
namespace Volcanic.AgenticCore.Extensions.A2A;
/// <summary>Parts not saved because they were over the size limit.</summary>
internal sealed record SkippedFiles(IReadOnlyList<string> Names, long MaxBytes);
/// <summary>What <see cref="A2AResults.SaveFiles"/> did: workspace-relative paths saved, and parts skipped for size.</summary>
internal sealed record SaveResult(IReadOnlyList<string> Saved, SkippedFiles Skipped);
/// <summary>How remote tasks read for the model: state names, the answer text, saved files, result and delivery texts.</summary>
/// <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";
@@ -49,66 +43,32 @@ internal static class A2AResults
return status.Length > 0 ? status : null;
}
/// <summary>Saves file parts (inline bytes) under <c>inbox/a2a/&lt;agent&gt;/</c>; returns workspace-relative paths.</summary>
public static SaveResult SaveFiles(string workspace, string agent, AgentTask task, long maxBytes)
{
var saved = new List<string>();
var skipped = 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) continue;
var name = Sanitize(part.Filename ?? artifact.Name ?? artifact.ArtifactId);
if (bytes.Length > maxBytes) { skipped.Add(name); continue; }
var relative = $"inbox/a2a/{agent}/{name}";
var full = Path.Combine(workspace, relative);
for (var n = 2; File.Exists(full) || Directory.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 new SaveResult(saved, new SkippedFiles(skipped, maxBytes));
}
/// <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, SkippedFiles? skipped = null)
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, skipped);
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, SkippedFiles? skipped = null)
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, skipped);
AppendBody(sb, task, files);
return sb.ToString();
}
private static void AppendBody(StringBuilder sb, AgentTask task, IReadOnlyList<string> files, SkippedFiles? skipped)
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);
if (files.Count > 0) sb.Append("\nFiles: ").Append(string.Join(", ", files));
if (skipped is { Names.Count: > 0 })
sb.Append($"\nFiles skipped (over {skipped.MaxBytes} bytes): ").Append(string.Join(", ", skipped.Names));
var links = Links(task);
if (links.Count > 0) sb.Append("\nLinks: ").Append(string.Join(", ", links));
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.");
}
@@ -121,13 +81,17 @@ internal static class A2AResults
return all.Length > 0 ? all : null;
}
private static string Sanitize(string? name)
/// <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)
{
var file = Path.GetFileName(name ?? "");
if (string.IsNullOrWhiteSpace(file) || file is "." or "..") return "file";
var invalid = Path.GetInvalidFileNameChars();
var clean = new string(file.Select(c => invalid.Contains(c) || char.IsWhiteSpace(c) ? '_' : c).Where(c => !char.IsControl(c)).ToArray());
if (clean.Length == 0 || clean is "." or "..") return "file";
return clean.Length > 80 ? clean[^80..] : clean;
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})" : "")}");
}
}
}

View File

@@ -115,7 +115,10 @@ internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatc
if (response.Task is not { } task)
{
var message = response.Message;
return $"Agent \"{entry.Name}\" replied (context {message?.ContextId}):\n{A2AResults.TextOf(message)}";
var reply = new StringBuilder($"Agent \"{entry.Name}\" replied (context {message?.ContextId}):\n{A2AResults.TextOf(message)}");
if (message is not null)
A2AResults.AppendFiles(reply, await A2AFiles.SaveAsync(workspace, message, agent, registry.Handler, settings, ct).ConfigureAwait(false));
return reply.ToString();
}
// The remote task exists from here on: whatever happens to this call, it is never left unwatched.
@@ -130,7 +133,7 @@ internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatc
if (ct.IsCancellationRequested) throw;
return A2AResults.Describe(entry.Name, task, []) + "\n" + StillRunning;
}
if (A2AResults.IsSettled(task.Status.State)) return DescribeSettled(workspace, entry.Name, task);
if (A2AResults.IsSettled(task.Status.State)) return await DescribeSettledAsync(workspace, entry.Name, agent, task, ct).ConfigureAwait(false);
// Not the caller's token: an abort now must not undo a watch half set up.
await registry.StartAsync(target, workspace, agent, task, settings, CancellationToken.None).ConfigureAwait(false);
return A2AResults.Describe(entry.Name, task, []) + "\n" + StillRunning;
@@ -147,7 +150,9 @@ internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatc
return await CallAsync(entry, ct, async agent =>
{
var task = await GetTaskAsync(agent, taskId, ct).ConfigureAwait(false);
return A2AResults.IsSettled(task.Status.State) ? DescribeSettled(workspace, entry.Name, task) : A2AResults.Describe(entry.Name, task, []);
return A2AResults.IsSettled(task.Status.State)
? await DescribeSettledAsync(workspace, entry.Name, agent, task, ct).ConfigureAwait(false)
: A2AResults.Describe(entry.Name, task, []);
}).ConfigureAwait(false);
}
@@ -173,7 +178,9 @@ internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatc
// Typically: already finished. Its watch is gone, so hand over the outcome here.
var task = await GetTaskAsync(agent, taskId, ct).ConfigureAwait(false);
return $"Could not cancel ({entry.Scrub(ex.Message)}). " +
(A2AResults.IsSettled(task.Status.State) ? DescribeSettled(workspace, entry.Name, task) : A2AResults.Describe(entry.Name, task, []));
(A2AResults.IsSettled(task.Status.State)
? await DescribeSettledAsync(workspace, entry.Name, agent, task, ct).ConfigureAwait(false)
: A2AResults.Describe(entry.Name, task, []));
}
}).ConfigureAwait(false);
}
@@ -186,10 +193,10 @@ internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatc
}
}
private string DescribeSettled(string workspace, string agent, AgentTask task)
private async Task<string> DescribeSettledAsync(string workspace, string name, RemoteAgent agent, AgentTask task, CancellationToken ct)
{
var saved = A2AResults.SaveFiles(workspace, agent, task, settings.MaxFileBytes);
return A2AResults.Describe(agent, task, saved.Saved, saved.Skipped);
var files = await A2AFiles.SaveAsync(workspace, task, agent, registry.Handler, settings, ct).ConfigureAwait(false);
return A2AResults.Describe(name, task, files);
}
/// <summary>One remote call, bounded by the registry's call timeout.</summary>

View File

@@ -83,6 +83,9 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
public void UseDelivery(IA2AResultDelivery delivery) => _delivery = delivery;
/// <summary>The shared handler, also for downloading files the agents link to.</summary>
internal HttpMessageHandler Handler => _handler;
internal RemoteAgentConnector Connector(A2AClientSettings settings) =>
new(_handler, TimeSpan.FromSeconds(settings.CardTimeoutSeconds));
@@ -343,8 +346,8 @@ public sealed class A2AWatchRegistry : IAsyncDisposable
}
if (task is not null && A2AResults.IsSettled(task.Status.State))
{
var saved = A2AResults.SaveFiles(watch.Workspace, record.Agent, task, settings.MaxFileBytes);
await FinishAsync(watch, A2AResults.DeliveryText(record.Agent, task, saved.Saved, saved.Skipped), agent).ConfigureAwait(false);
var files = await A2AFiles.SaveAsync(watch.Workspace, task, agent, _handler, settings, ct).ConfigureAwait(false);
await FinishAsync(watch, A2AResults.DeliveryText(record.Agent, task, files), agent).ConfigureAwait(false);
return;
}
failures = 0;

View File

@@ -15,6 +15,9 @@ internal sealed class RemoteAgent(RemoteAgentEntry entry, AgentCard card, IA2ACl
public RemoteAgentEntry Entry => entry;
public AgentCard Card => card;
public IA2AClient Client => client;
/// <summary>Sends the agent's key: use only for the agent's own origin.</summary>
public HttpClient Http => http;
public bool Streaming => card.Capabilities?.Streaming == true;
public bool SupportsPush => card.Capabilities?.PushNotifications == true;

View File

@@ -0,0 +1,158 @@
using System.Net;
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 A2AFilesTests : IDisposable
{
private const string AgentUrl = "https://agent.example/a2a/agents/abc";
private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-files", Guid.NewGuid().ToString("N"));
private readonly FakeHandler _handler = new();
private readonly A2AClientSettings _settings = new() { MaxReceivedFileBytes = 1024 };
private static AgentTask Task_(A2AMessage? status = null, List<A2AMessage>? history = null, params Artifact[] artifacts) => new()
{
Id = "task-1", ContextId = "ctx-1",
Status = new A2ATaskStatus { State = TaskState.Completed, Message = status },
Artifacts = [.. artifacts],
History = history,
};
private static A2AMessage Msg(Role role, params Part[] parts) => new() { Role = role, MessageId = Guid.NewGuid().ToString("N"), Parts = [.. parts] };
private static Part Raw(string text, string name, string? type = "text/plain") => Part.FromRaw(Encoding.UTF8.GetBytes(text), type, name);
private RemoteAgent Agent()
{
var http = new HttpClient(_handler, disposeHandler: false);
http.DefaultRequestHeaders.Add("X-API-Key", "secret-key");
var card = new AgentCard { Name = "a", SupportedInterfaces = [new AgentInterface { Url = AgentUrl }] };
return new RemoteAgent(new RemoteAgentEntry("crm", AgentUrl, "secret-key", null), card, A2AClientFactory.Create(card, http), http);
}
private Task<IReadOnlyList<ReceivedFile>> SaveAsync(AgentTask task, RemoteAgent? agent = null) =>
A2AFiles.SaveAsync(_ws, task, agent, _handler, _settings, CancellationToken.None);
[Fact]
public async Task InlineBytes_FromArtifactsStatusAndHistory_SavedPerTask()
{
var task = Task_(
status: Msg(Role.Agent, Part.FromText("done"), Raw("# skill", "SKILL.md", "text/markdown")),
history: [Msg(Role.Agent, Raw("old", "old.txt")), Msg(Role.User, Part.FromText("go"), Raw("mine", "mine.txt")), Msg(Role.Agent, Raw("log", "run.log"))],
artifacts: new Artifact { ArtifactId = "a1", Name = "plan.pdf", Parts = [Raw("pdf!", "../../plan.pdf", "application/pdf")] });
var files = await SaveAsync(task);
Assert.Equal(["inbox/a2a/task-1/plan.pdf", "inbox/a2a/task-1/SKILL.md", "inbox/a2a/task-1/run.log"], files.Select(f => f.Path));
Assert.Equal("# skill", File.ReadAllText(Path.Combine(_ws, "inbox/a2a/task-1/SKILL.md")));
Assert.Equal((7L, "text/markdown"), (files[1].Bytes!.Value, files[1].MediaType!));
}
[Fact]
public async Task SamePartTwice_SavedOnce_AndSavingAgainOverwrites()
{
var part = Raw("x", "a.txt");
var task = Task_(status: Msg(Role.Agent, part), artifacts: new Artifact { ArtifactId = "a", Parts = [part, Raw("yy", "a.txt")] });
var first = await SaveAsync(task);
Assert.Equal(["inbox/a2a/task-1/a.txt", "inbox/a2a/task-1/a-2.txt"], first.Select(f => f.Path));
var second = await SaveAsync(task);
Assert.Equal(first.Select(f => f.Path), second.Select(f => f.Path));
Assert.Equal(2, Directory.GetFiles(Path.Combine(_ws, "inbox/a2a/task-1")).Length);
}
[Fact]
public async Task OversizedInline_NotSaved_ReportedWithSize()
{
var task = Task_(artifacts: new Artifact { ArtifactId = "a", Parts = [Part.FromRaw(new byte[2000], "application/gzip", "crm.tar.gz")] });
var file = Assert.Single(await SaveAsync(task));
Assert.Null(file.Path);
Assert.Equal(2000, file.Bytes);
Assert.Equal("over 1024 bytes", file.Problem);
}
[Fact]
public async Task UrlOnAgentOrigin_DownloadedWithKey()
{
_handler.Respond = req => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes(req.Headers.TryGetValues("X-API-Key", out var v) ? string.Concat(v) : "none")),
};
var task = Task_(artifacts: new Artifact { ArtifactId = "a", Parts = [Part.FromUrl("https://agent.example/files/crm%20build.tar.gz", "application/gzip")] });
using var agent = Agent();
var file = Assert.Single(await SaveAsync(task, agent));
Assert.Equal("inbox/a2a/task-1/crm_build.tar.gz", file.Path);
Assert.Equal("secret-key", File.ReadAllText(Path.Combine(_ws, file.Path!)));
Assert.Equal("application/gzip", file.MediaType);
}
[Fact]
public async Task UrlElsewhere_DownloadedWithoutKey_AndPlainHttpRefused()
{
_handler.Respond = req => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(Encoding.UTF8.GetBytes(req.Headers.Contains("X-API-Key") ? "leaked" : "ok")),
};
var task = Task_(artifacts: new Artifact { ArtifactId = "a", Parts =
[Part.FromUrl("https://cdn.example/x.bin", null, "x.bin"), Part.FromUrl("http://cdn.example/y.bin")] });
using var agent = Agent();
var files = await SaveAsync(task, agent);
Assert.Equal("ok", File.ReadAllText(Path.Combine(_ws, files[0].Path!)));
Assert.Null(files[1].Path);
Assert.Equal("not https, not downloaded", files[1].Problem);
Assert.Single(_handler.Requests);
}
[Fact]
public async Task UrlTooBig_OrFailing_LeavesNothing()
{
_handler.Respond = req => req.RequestUri!.AbsolutePath == "/big"
? new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(new MemoryStream(new byte[5000])) } // no length up front
: new HttpResponseMessage(HttpStatusCode.NotFound);
var task = Task_(artifacts: new Artifact { ArtifactId = "a", Parts = [Part.FromUrl("https://cdn.example/big"), Part.FromUrl("https://cdn.example/gone")] });
var files = await SaveAsync(task);
Assert.Equal("over 1024 bytes", files[0].Problem);
Assert.Equal("download failed (404)", files[1].Problem);
Assert.Empty(Directory.Exists(Path.Combine(_ws, "inbox")) ? Directory.GetFiles(Path.Combine(_ws, "inbox"), "*", SearchOption.AllDirectories) : []);
}
[Fact]
public async Task MessageReply_FilesSavedUnderItsId()
{
var message = Msg(Role.Agent, Part.FromText("here"), Raw("x", "r.txt"));
var file = Assert.Single(await A2AFiles.SaveAsync(_ws, message, null, _handler, _settings, CancellationToken.None));
Assert.Equal($"inbox/a2a/{message.MessageId}/r.txt", file.Path);
}
[Fact]
public async Task Names_Sanitised()
{
var task = Task_(artifacts:
[
new Artifact { ArtifactId = "a", Parts = [Raw("x", "..")] },
new Artifact { ArtifactId = "b", Parts = [Raw("y", "a\u0001b.txt")] },
]);
Assert.Equal(["inbox/a2a/task-1/file", "inbox/a2a/task-1/ab.txt"], (await SaveAsync(task)).Select(f => f.Path));
}
public void Dispose()
{
_handler.Dispose();
try { Directory.Delete(_ws, true); } catch (IOException) { }
}
private sealed class FakeHandler : HttpMessageHandler
{
public Func<HttpRequestMessage, HttpResponseMessage> Respond { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.NotFound);
public List<HttpRequestMessage> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request);
return Task.FromResult(Respond(request));
}
}
}

View File

@@ -37,19 +37,6 @@ public sealed class A2AResultsTests : IDisposable
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.Saved);
Assert.Equal("inbox/a2a/travel-bot/plan.pdf", file);
Assert.Equal("pdf!", File.ReadAllText(Path.Combine(_ws, file)));
}
[Fact]
public void Describe_InputRequired_HasQuestionAndHint()
{
@@ -65,10 +52,12 @@ public sealed class A2AResultsTests : IDisposable
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"]);
new Artifact { ArtifactId = "response", Parts = [Part.FromText("Done")] }),
[new ReceivedFile("x.pdf", 4, "application/pdf", "inbox/a2a/task-123456789/x.pdf"),
new ReceivedFile("big.tar.gz", 300, null, null, "over 100 bytes")]);
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);
Assert.Contains("\nFiles:\n- inbox/a2a/task-123456789/x.pdf (4 bytes, application/pdf)\n- big.tar.gz (300 bytes): not saved, over 100 bytes", text);
}
[Fact]
@@ -80,26 +69,5 @@ public sealed class A2AResultsTests : IDisposable
Assert.True(text.IndexOf("Which date?", StringComparison.Ordinal) < text.IndexOf("old answer", StringComparison.Ordinal));
}
[Fact]
public void SaveFiles_DotDotName_BecomesFile_AndControlCharsStripped()
{
var task = Task_(TaskState.Completed, null,
new Artifact { ArtifactId = "a", Parts = [Part.FromRaw(Encoding.UTF8.GetBytes("x"), "text/plain", "..")] },
new Artifact { ArtifactId = "b", Parts = [Part.FromRaw(Encoding.UTF8.GetBytes("y"), "text/plain", "a\u0001b.txt")] });
var files = A2AResults.SaveFiles(_ws, "bot", task, 1024).Saved;
Assert.Equal(["inbox/a2a/bot/file", "inbox/a2a/bot/ab.txt"], files);
}
[Fact]
public void SaveFiles_ReportsOversizedParts_AndDescribeListsThem()
{
var task = Task_(TaskState.Completed, null,
new Artifact { ArtifactId = "a", Parts = [Part.FromRaw(new byte[100], "text/plain", "big.bin")] });
var result = A2AResults.SaveFiles(_ws, "bot", task, 10);
Assert.Empty(result.Saved);
Assert.Equal(["big.bin"], result.Skipped.Names);
Assert.Contains("Files skipped (over 10 bytes): big.bin", A2AResults.Describe("bot", task, result.Saved, result.Skipped));
}
public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } }
}

View File

@@ -143,8 +143,9 @@ public class A2AToolTests
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 taskId = Field(text, "task");
Assert.Contains($"inbox/a2a/{taskId}/reply.txt (11 bytes", text);
Assert.Equal("remote file", File.ReadAllText(Path.Combine(host.Workspace, "inbox", "a2a", taskId, "reply.txt")));
var inbound = Assert.Single(Directory.GetFiles(Path.Combine(remote.Workspace, "inbox"), "a2a-client-*"));
Assert.Equal("my notes", File.ReadAllText(inbound));
}