diff --git a/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs b/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs new file mode 100644 index 0000000..43a6121 --- /dev/null +++ b/extensions/Volcanic.AgenticCore.Extensions.A2A/AgentsFile.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Volcanic.AgenticCore.Extensions.A2A; + +public sealed record RemoteAgentEntry(string Name, string Url, string? Key, string? Description); + +/// Skipped entries, described without keys. +public sealed record AgentsFileResult(IReadOnlyList Agents, IReadOnlyList Problems, string? Error); + +/// +/// <workspace>/.volcanic/a2a/agents.json: remote agents the assistant may call. A plain file the agent and the +/// user edit; read on every call (no cache). +/// +public static partial class AgentsFile +{ + [GeneratedRegex("^[a-z0-9-]{1,40}$", RegexOptions.CultureInvariant)] + private static partial Regex NamePattern(); + + public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "a2a", "agents.json"); + + public static RemoteAgentEntry? Find(string workspace, string name) => + Read(workspace).Agents.FirstOrDefault(a => a.Name == name); + + public static AgentsFileResult Read(string workspace) + { + var path = PathFor(workspace); + if (!File.Exists(path)) return new([], [], null); + JsonElement root; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + root = doc.RootElement.Clone(); + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return new([], [], $".volcanic/a2a/agents.json could not be read: {ex.GetType().Name}."); + } + if (root.ValueKind != JsonValueKind.Array) + return new([], [], ".volcanic/a2a/agents.json must be a JSON array of agents."); + + var agents = new List(); + var problems = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var index = 0; + foreach (var item in root.EnumerateArray()) + { + index++; + if (item.ValueKind != JsonValueKind.Object) { problems.Add($"entry {index} is not an object"); continue; } + var name = Str(item, "name"); + var url = Str(item, "url"); + if (name is null || !NamePattern().IsMatch(name)) { problems.Add($"entry {index}: name must match [a-z0-9-]{{1,40}}"); continue; } + if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)) + { problems.Add($"{name}: url must be an absolute http(s) URL"); continue; } + if (!seen.Add(name)) { problems.Add($"{name}: duplicate name"); continue; } + agents.Add(new RemoteAgentEntry(name, url, Str(item, "key"), Str(item, "description"))); + } + return new(agents, problems, null); + } + + private static string? Str(JsonElement e, string property) => + e.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String && v.GetString() is { Length: > 0 } s ? s : null; +} diff --git a/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs new file mode 100644 index 0000000..4150125 --- /dev/null +++ b/tests/Volcanic.AgenticCore.Extensions.Tests/A2A/AgentsFileTests.cs @@ -0,0 +1,59 @@ +using Volcanic.AgenticCore.Extensions.A2A; + +namespace Volcanic.AgenticCore.Extensions.Tests.A2A; + +public sealed class AgentsFileTests : IDisposable +{ + private readonly string _ws = Path.Combine(Path.GetTempPath(), "a2a-agents", Guid.NewGuid().ToString("N")); + + private void Write(string json) + { + Directory.CreateDirectory(Path.GetDirectoryName(AgentsFile.PathFor(_ws))!); + File.WriteAllText(AgentsFile.PathFor(_ws), json); + } + + [Fact] + public void Missing_NoAgentsNoError() + { + var r = AgentsFile.Read(_ws); + Assert.Empty(r.Agents); + Assert.Null(r.Error); + } + + [Fact] + public void Agents_MalformedFile_ReportsFile() + { + Write("{ nope"); + var r = AgentsFile.Read(_ws); + Assert.Empty(r.Agents); + Assert.Contains("agents.json", r.Error); + } + + [Fact] + public void Read_ValidatesEntries_AndNeverEchoesKeys() + { + Write(""" + [ { "name": "travel-bot", "url": "https://travel.example/", "key": "secret-1", "description": "Trips" }, + { "name": "Bad Name", "url": "https://x.example/", "key": "secret-2" }, + { "name": "no-url" }, + { "name": "ftp", "url": "ftp://x.example/" }, + { "name": "travel-bot", "url": "https://dup.example/" } ] + """); + var r = AgentsFile.Read(_ws); + var agent = Assert.Single(r.Agents); + Assert.Equal(new RemoteAgentEntry("travel-bot", "https://travel.example/", "secret-1", "Trips"), agent); + Assert.Equal(4, r.Problems.Count); + Assert.DoesNotContain(r.Problems, p => p.Contains("secret")); + } + + [Fact] + public void Agents_FileEdited_AppliesToNextCall() + { + Write("""[ { "name": "a", "url": "https://a.example/" } ]"""); + Assert.NotNull(AgentsFile.Find(_ws, "a")); + Write("[]"); + Assert.Null(AgentsFile.Find(_ws, "a")); + } + + public void Dispose() { try { Directory.Delete(_ws, true); } catch (IOException) { } } +}