Hour-long remote tasks were never delivered: a subscribe watch held one SSE stream for up to a day, the remote sent nothing while its agent worked without text, and once the quiet connection died on the way (no FIN/RST) the watch waited on it with no error and no log line. - A subscription lasts at most 5 minutes (SubscriptionSlice), then GetTask and a new subscription: a pause, not a failure. A slice ended mid-read counts as ours even when it surfaces as an I/O error. - The shared handler connects with TCP keepalive (60 s, every 15 s, 4 probes) and HTTP/2 pings. - Log lines when a watch starts or is restored, when a slice ends on a running task, when a result is delivered or kept; the Telegram host's console log carries a UTC timestamp. - action=status on a settled task ends its watch, so the result isn't delivered a second time. - A "context busy" refusal of send says nothing was sent, names the watched task in that context and that its result will arrive by itself; refusals never touch a watch.
403 lines
22 KiB
C#
403 lines
22 KiB
C#
using System.ComponentModel;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using A2A;
|
|
using Volcanic.AgenticCore.Abstractions.Tools;
|
|
using A2AMessage = global::A2A.Message;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.A2A;
|
|
|
|
public sealed record A2AArgs(
|
|
[property: Description("\"agents\" (list remote agents), \"send\" (message an agent), \"status\" (check a task) or \"cancel\" (cancel a task)")] string Action,
|
|
[property: Description("Agent name from .volcanic/a2a/agents.json (send, status, cancel)")] string? Agent = null,
|
|
[property: Description("Text to send (send)")] string? Message = null,
|
|
[property: Description("Continue an earlier conversation with this agent: the context id from a previous result (send)")] string? ContextId = null,
|
|
[property: Description("A task id: answer the agent's question (send), or the task to check or cancel (status, cancel)")] string? TaskId = null,
|
|
[property: Description("Workspace files to attach (send)")] string[]? Files = null);
|
|
|
|
/// <summary>The <c>a2a</c> tool: remote agents from <c>agents.json</c>. Errors are thrown with readable, key-free messages.</summary>
|
|
internal sealed class A2ATool(A2AClientSettings settings, string target, A2AWatchRegistry registry, Func<int> inboundHops) : Tool<A2AArgs>
|
|
{
|
|
/// <summary>Symlink hops followed when confining a path (a loop fails earlier in the OS).</summary>
|
|
private const int MaxLinkDepth = 40;
|
|
|
|
public override string Name => "a2a";
|
|
|
|
public override string Description =>
|
|
"Talk to other AI agents over the A2A protocol. Agents are configured in .volcanic/a2a/agents.json " +
|
|
"([{\"name\", \"url\", \"key\"}]). Use action=agents to see them and their skills, action=send to delegate work.";
|
|
|
|
public override string? PromptSnippet => "a2a: delegate to other AI agents listed in .volcanic/a2a/agents.json";
|
|
|
|
public override IReadOnlyList<string> PromptGuidelines =>
|
|
[
|
|
"When a remote agent asks a question (input-required), answer with action=send and its taskId; ask the user first if you don't know the answer.",
|
|
"Long remote tasks are watched for you: their result arrives later as a message starting with [A2A result from …].",
|
|
];
|
|
|
|
protected override async Task<ToolResult> ExecuteAsync(A2AArgs args, ToolInvocation invocation, CancellationToken cancellationToken)
|
|
{
|
|
var workspace = invocation.Context.Cwd;
|
|
return args.Action?.Trim().ToLowerInvariant() switch
|
|
{
|
|
"agents" => ToolResult.Text(await ListAsync(workspace, cancellationToken).ConfigureAwait(false)),
|
|
"send" => ToolResult.Text(await SendAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
|
|
"status" => ToolResult.Text(await StatusAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
|
|
"cancel" => ToolResult.Text(await CancelAsync(workspace, args, cancellationToken).ConfigureAwait(false)),
|
|
_ => throw new ArgumentException("action must be agents, send, status or cancel."),
|
|
};
|
|
}
|
|
|
|
private async Task<string> ListAsync(string workspace, CancellationToken ct)
|
|
{
|
|
var file = AgentsFile.Read(workspace);
|
|
if (file.Error is not null) return file.Error;
|
|
if (file.Agents.Count == 0)
|
|
{
|
|
var none = "No remote agents configured. Add them to .volcanic/a2a/agents.json: [{\"name\": \"travel-bot\", \"url\": \"https://…\", \"key\": \"…\"}]";
|
|
return file.Problems.Count == 0 ? none : none + string.Concat(file.Problems.Select(p => $"\n(skipped: {p})"));
|
|
}
|
|
var sb = new StringBuilder("Remote agents:");
|
|
foreach (var entry in file.Agents)
|
|
{
|
|
try
|
|
{
|
|
using var agent = await registry.Connector(settings).ConnectAsync(entry, ct).ConfigureAwait(false);
|
|
var card = agent.Card;
|
|
sb.Append($"\n- {entry.Name}: {entry.Description ?? card.Description} ({card.Name})");
|
|
var caps = new List<string>();
|
|
if (agent.Streaming) caps.Add("streaming");
|
|
if (agent.SupportsPush) caps.Add("push");
|
|
if (caps.Count > 0) sb.Append($" [{string.Join(", ", caps)}]");
|
|
foreach (var skill in card.Skills ?? [])
|
|
{
|
|
sb.Append($"\n - skill {skill.Id}: {skill.Name} — {skill.Description}");
|
|
if (skill.Examples is { Count: > 0 } examples) sb.Append($" (e.g. {string.Join("; ", examples.Take(3))})");
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is not OutOfMemoryException && (ex is not OperationCanceledException || !ct.IsCancellationRequested))
|
|
{
|
|
sb.Append($"\n- {entry.Name}: unreachable ({entry.Scrub(ex.Message)})");
|
|
}
|
|
}
|
|
foreach (var problem in file.Problems) sb.Append($"\n(skipped: {problem})");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private async Task<string> SendAsync(string workspace, A2AArgs args, CancellationToken ct)
|
|
{
|
|
var entry = Entry(workspace, args.Agent);
|
|
if (string.IsNullOrWhiteSpace(args.Message) && args.Files is not { Length: > 0 })
|
|
throw new ArgumentException("send needs a message or files.");
|
|
var parts = new List<Part>();
|
|
if (!string.IsNullOrWhiteSpace(args.Message)) parts.Add(Part.FromText(args.Message));
|
|
parts.AddRange(await FilePartsAsync(workspace, args.Files ?? [], ct).ConfigureAwait(false));
|
|
|
|
return await CallAsync(entry, ct, async agent =>
|
|
{
|
|
SendMessageResponse response;
|
|
try
|
|
{
|
|
using var bounded = Bounded(ct);
|
|
response = await agent.Client.SendMessageAsync(new SendMessageRequest
|
|
{
|
|
Message = new A2AMessage
|
|
{
|
|
Role = Role.User,
|
|
MessageId = Guid.NewGuid().ToString("N"),
|
|
Parts = parts,
|
|
ContextId = string.IsNullOrWhiteSpace(args.ContextId) ? null : args.ContextId,
|
|
TaskId = string.IsNullOrWhiteSpace(args.TaskId) ? null : args.TaskId,
|
|
},
|
|
Configuration = new SendMessageConfiguration { ReturnImmediately = true },
|
|
Metadata = new Dictionary<string, JsonElement> { ["volcanic.hops"] = JsonSerializer.SerializeToElement(inboundHops() + 1) },
|
|
}, bounded.Token).ConfigureAwait(false);
|
|
}
|
|
catch (A2AException ex) when (IsContextBusy(ex) && !string.IsNullOrWhiteSpace(args.ContextId))
|
|
{
|
|
throw new InvalidOperationException(BusyContext(entry, args.ContextId!), ex);
|
|
}
|
|
|
|
if (response.Task is not { } task)
|
|
{
|
|
var message = response.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.
|
|
try
|
|
{
|
|
task = await WaitAsync(agent, task, ct).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (ex is not OutOfMemoryException)
|
|
{
|
|
// Aborted, or the remote stopped answering while we waited: a watch takes over (its result still arrives).
|
|
await registry.StartAsync(target, workspace, agent, task, settings, CancellationToken.None).ConfigureAwait(false);
|
|
if (ct.IsCancellationRequested) throw;
|
|
return A2AResults.Describe(entry.Name, task, []) + "\n" + StillRunning;
|
|
}
|
|
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;
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
private const string StillRunning =
|
|
"It is still running: the result will be delivered to this conversation when it is ready. You can also check it with action=status.";
|
|
|
|
private async Task<string> StatusAsync(string workspace, A2AArgs args, CancellationToken ct)
|
|
{
|
|
var entry = Entry(workspace, args.Agent);
|
|
var taskId = Required(args.TaskId, "taskId");
|
|
return await CallAsync(entry, ct, async agent =>
|
|
{
|
|
var task = await GetTaskAsync(agent, taskId, ct).ConfigureAwait(false);
|
|
if (!A2AResults.IsSettled(task.Status.State)) return A2AResults.Describe(entry.Name, task, []);
|
|
var described = await DescribeSettledAsync(workspace, entry.Name, agent, task, ct).ConfigureAwait(false);
|
|
// The outcome is in this conversation now: a watch of the task would only deliver it a second time.
|
|
await registry.StopWatchingAsync(target, workspace, taskId).ConfigureAwait(false);
|
|
return described;
|
|
}).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>The remote refuses a message to a context whose task still runs (our server and VolcanicAgents say "Context busy").</summary>
|
|
private static bool IsContextBusy(A2AException ex) =>
|
|
ex.ErrorCode == A2AErrorCode.UnsupportedOperation && ex.Message.Contains("busy", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private string BusyContext(RemoteAgentEntry entry, string contextId)
|
|
{
|
|
var running = registry.List(target).FirstOrDefault(r => r.Agent == entry.Name && r.ContextId == contextId);
|
|
return running is null
|
|
? $"Agent \"{entry.Name}\" is still working on an earlier task in context {contextId} and refused this message. " +
|
|
"Nothing was sent. Send it again once that task has ended, or start a new conversation without contextId."
|
|
: $"Agent \"{entry.Name}\" is still working on task {running.TaskId} in context {contextId} and refused this message. " +
|
|
"Nothing was sent, and that task is still watched: its result will arrive here by itself. " +
|
|
"Send this message again after that result, or start a new conversation without contextId.";
|
|
}
|
|
|
|
private async Task<string> CancelAsync(string workspace, A2AArgs args, CancellationToken ct)
|
|
{
|
|
var entry = Entry(workspace, args.Agent);
|
|
var taskId = Required(args.TaskId, "taskId");
|
|
// First, so a "canceled" outcome is never delivered as a follow-up.
|
|
var stopped = await registry.StopWatchingAsync(target, workspace, taskId).ConfigureAwait(false);
|
|
try
|
|
{
|
|
return await CallAsync(entry, ct, async agent =>
|
|
{
|
|
try
|
|
{
|
|
AgentTask task;
|
|
using (var bounded = Bounded(ct))
|
|
task = await agent.Client.CancelTaskAsync(new CancelTaskRequest { Id = taskId }, bounded.Token).ConfigureAwait(false);
|
|
return A2AResults.Describe(entry.Name, task, []);
|
|
}
|
|
catch (A2AException ex)
|
|
{
|
|
// 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)
|
|
? await DescribeSettledAsync(workspace, entry.Name, agent, task, ct).ConfigureAwait(false)
|
|
: A2AResults.Describe(entry.Name, task, []));
|
|
}
|
|
}).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex) when (stopped is not null && ex is not OutOfMemoryException)
|
|
{
|
|
// Unreachable, too slow or aborted: the task may still run, so its watch goes on.
|
|
await registry.RearmAsync(workspace, stopped, settings).ConfigureAwait(false);
|
|
if (ex is InvalidOperationException) throw new InvalidOperationException(ex.Message + " The task is still watched.", ex);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<string> DescribeSettledAsync(string workspace, string name, RemoteAgent agent, AgentTask task, CancellationToken ct)
|
|
{
|
|
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>
|
|
private CancellationTokenSource Bounded(CancellationToken ct)
|
|
{
|
|
var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
bounded.CancelAfter(registry.CallTimeout);
|
|
return bounded;
|
|
}
|
|
|
|
private async Task<AgentTask> GetTaskAsync(RemoteAgent agent, string taskId, CancellationToken ct)
|
|
{
|
|
using var bounded = Bounded(ct);
|
|
return await agent.Client.GetTaskAsync(new GetTaskRequest { Id = taskId }, bounded.Token).ConfigureAwait(false);
|
|
}
|
|
|
|
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(1);
|
|
|
|
/// <summary>
|
|
/// Wait up to WaitSeconds for the task to settle: subscribe (streaming agents) or poll every second; a stream that
|
|
/// fails or ends unsettled falls back to polling. Throws on the caller's cancel (the caller then hands over to a watch).
|
|
/// </summary>
|
|
private async Task<AgentTask> WaitAsync(RemoteAgent agent, AgentTask task, CancellationToken ct)
|
|
{
|
|
if (A2AResults.IsSettled(task.Status.State) || settings.WaitSeconds <= 0) return task;
|
|
using var wait = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
wait.CancelAfter(TimeSpan.FromSeconds(settings.WaitSeconds));
|
|
var streaming = agent.Streaming;
|
|
var pause = !streaming; // polling: the task was just read
|
|
while (true)
|
|
{
|
|
try
|
|
{
|
|
if (streaming)
|
|
{
|
|
AgentTask current = task;
|
|
await foreach (var ev in agent.Client.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = task.Id }, wait.Token).ConfigureAwait(false))
|
|
{
|
|
current = TaskProjection.Apply(current, ev) ?? current;
|
|
if (A2AResults.IsSettled(current.Status.State)) return current;
|
|
}
|
|
streaming = false; // the stream ended unsettled: poll for the rest of the wait
|
|
pause = false;
|
|
continue;
|
|
}
|
|
if (pause) await Task.Delay(PollInterval, registry.Time, wait.Token).ConfigureAwait(false);
|
|
pause = true;
|
|
var latest = await agent.Client.GetTaskAsync(new GetTaskRequest { Id = task.Id }, wait.Token).ConfigureAwait(false);
|
|
if (A2AResults.IsSettled(latest.Status.State)) return latest;
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { break; } // waited long enough
|
|
catch (Exception ex) when (streaming && ex is A2AException or HttpRequestException or IOException)
|
|
{
|
|
streaming = false; // e.g. subscribing to a task that just finished, or a refused stream: poll at once
|
|
pause = false;
|
|
}
|
|
}
|
|
return await GetTaskAsync(agent, task.Id, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private async Task<string> CallAsync(RemoteAgentEntry entry, CancellationToken ct, Func<RemoteAgent, Task<string>> call)
|
|
{
|
|
try
|
|
{
|
|
using var agent = await registry.Connector(settings).ConnectAsync(entry, ct).ConfigureAwait(false);
|
|
return await call(agent).ConfigureAwait(false);
|
|
}
|
|
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
{
|
|
throw new InvalidOperationException($"Agent \"{entry.Name}\" rejected the key ({(int)ex.StatusCode!}). Check its key in .volcanic/a2a/agents.json.");
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
throw new InvalidOperationException($"Could not reach agent \"{entry.Name}\": {entry.Scrub(ex.Message)}");
|
|
}
|
|
catch (A2AException ex)
|
|
{
|
|
throw new InvalidOperationException($"Agent \"{entry.Name}\" refused: {entry.Scrub(ex.Message)} ({ex.ErrorCode}).");
|
|
}
|
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
{
|
|
throw new InvalidOperationException($"Agent \"{entry.Name}\" did not answer in time.");
|
|
}
|
|
}
|
|
|
|
private static RemoteAgentEntry Entry(string workspace, string? name)
|
|
{
|
|
var file = AgentsFile.Read(workspace);
|
|
if (file.Error is not null) throw new InvalidOperationException(file.Error);
|
|
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("agent is required.");
|
|
return file.Agents.FirstOrDefault(a => a.Name == name)
|
|
?? throw new ArgumentException($"Unknown agent \"{name}\". Configured: {(file.Agents.Count == 0 ? "none (see .volcanic/a2a/agents.json)" : string.Join(", ", file.Agents.Select(a => a.Name)))}.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the attachments: workspace files (symlinks resolved and confined), together at most
|
|
/// <see cref="A2AClientSettings.MaxFileBytes"/>, each read through one opened stream so a file growing meanwhile is
|
|
/// still capped.
|
|
/// </summary>
|
|
private async Task<List<Part>> FilePartsAsync(string workspace, IReadOnlyList<string> paths, CancellationToken ct)
|
|
{
|
|
var parts = new List<Part>();
|
|
var remaining = settings.MaxFileBytes;
|
|
foreach (var path in paths)
|
|
{
|
|
var full = ConfinedPath(workspace, path);
|
|
if (Directory.Exists(full) || !File.Exists(full)) throw new ArgumentException($"{path} does not exist or is not a file.");
|
|
// Special files (FIFOs, devices) aren't detected: a FIFO would block here until the run is aborted.
|
|
byte[] bytes;
|
|
await using (var stream = new FileStream(full, new FileStreamOptions
|
|
{ Mode = FileMode.Open, Access = FileAccess.Read, Share = FileShare.Read, Options = FileOptions.Asynchronous }))
|
|
{
|
|
using var copy = new MemoryStream();
|
|
var chunk = new byte[81920];
|
|
int n;
|
|
while ((n = await stream.ReadAsync(chunk, ct).ConfigureAwait(false)) > 0)
|
|
{
|
|
if (copy.Length + n > remaining)
|
|
throw new ArgumentException(remaining == settings.MaxFileBytes
|
|
? $"{path} is larger than {settings.MaxFileBytes} bytes."
|
|
: $"the attached files together are larger than {settings.MaxFileBytes} bytes.");
|
|
copy.Write(chunk, 0, n);
|
|
}
|
|
bytes = copy.ToArray();
|
|
}
|
|
remaining -= bytes.Length;
|
|
parts.Add(Part.FromRaw(bytes, MediaType(full), Path.GetFileName(full)));
|
|
}
|
|
return parts;
|
|
}
|
|
|
|
/// <summary>The real path of a workspace file: inside the workspace lexically and after every symlink is resolved.</summary>
|
|
internal static string ConfinedPath(string workspace, string path)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("a file path is empty.");
|
|
var root = Path.GetFullPath(workspace);
|
|
var full = Path.GetFullPath(Path.Combine(root, path));
|
|
if (!IsInside(full, root)) throw new ArgumentException($"{path} is outside the workspace.");
|
|
if (!IsInside(RealPath(full, 0), RealPath(root, 0))) throw new ArgumentException($"{path} points outside the workspace.");
|
|
return full;
|
|
}
|
|
|
|
private static bool IsInside(string path, string root)
|
|
{
|
|
var prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
|
|
return path.StartsWith(prefix, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Resolves symlinks in every existing component of an absolute path.</summary>
|
|
private static string RealPath(string full, int depth)
|
|
{
|
|
if (depth > MaxLinkDepth) throw new ArgumentException("too many symbolic links.");
|
|
var current = Path.GetPathRoot(full)!;
|
|
foreach (var segment in full[current.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var next = Path.Combine(current, segment);
|
|
FileSystemInfo info = Directory.Exists(next) ? new DirectoryInfo(next) : new FileInfo(next);
|
|
// A link's target may itself sit under links: resolve it again from the top.
|
|
if (info.LinkTarget is not null && info.ResolveLinkTarget(true) is { } resolved) next = RealPath(resolved.FullName, depth + 1);
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
private static string MediaType(string path) => Path.GetExtension(path).ToLowerInvariant() switch
|
|
{
|
|
".png" => "image/png",
|
|
".jpg" or ".jpeg" => "image/jpeg",
|
|
".pdf" => "application/pdf",
|
|
".txt" => "text/plain",
|
|
".md" => "text/markdown",
|
|
".json" => "application/json",
|
|
".csv" => "text/csv",
|
|
_ => "application/octet-stream",
|
|
};
|
|
|
|
private static string Required(string? value, string name) =>
|
|
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"{name} is required.") : value;
|
|
}
|