fix(a2a-client): fake MCP server on the ASP.NET framework, question-first results, safer file names
This commit is contained in:
@@ -4,6 +4,12 @@ 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>
|
||||
internal static class A2AResults
|
||||
{
|
||||
@@ -44,17 +50,19 @@ internal static class A2AResults
|
||||
}
|
||||
|
||||
/// <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)
|
||||
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 || bytes.Length > maxBytes) continue;
|
||||
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); n++)
|
||||
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);
|
||||
@@ -63,7 +71,7 @@ internal static class A2AResults
|
||||
File.WriteAllBytes(full, bytes);
|
||||
saved.Add(relative);
|
||||
}
|
||||
return saved;
|
||||
return new SaveResult(saved, new SkippedFiles(skipped, maxBytes));
|
||||
}
|
||||
|
||||
/// <summary>Links the remote agent returned instead of bytes (not downloaded).</summary>
|
||||
@@ -72,40 +80,54 @@ internal static class A2AResults
|
||||
.Select(p => p.Url!).ToList();
|
||||
|
||||
/// <summary>The tool result for a task.</summary>
|
||||
public static string Describe(string agent, AgentTask task, IReadOnlyList<string> files)
|
||||
public static string Describe(string agent, AgentTask task, IReadOnlyList<string> files, SkippedFiles? skipped = null)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append($"Agent \"{agent}\": task {task.Id} (context {task.ContextId}) is {StateName(task.Status.State)}.");
|
||||
AppendBody(sb, task, files);
|
||||
AppendBody(sb, task, files, skipped);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>The follow-up prompt a watch delivers.</summary>
|
||||
public static string DeliveryText(string agent, AgentTask task, IReadOnlyList<string> files)
|
||||
public static string DeliveryText(string agent, AgentTask task, IReadOnlyList<string> files, SkippedFiles? skipped = null)
|
||||
{
|
||||
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);
|
||||
AppendBody(sb, task, files, skipped);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendBody(StringBuilder sb, AgentTask task, IReadOnlyList<string> files)
|
||||
private static void AppendBody(StringBuilder sb, AgentTask task, IReadOnlyList<string> files, SkippedFiles? skipped)
|
||||
{
|
||||
if (FinalText(task) is { } text) sb.Append('\n').Append(text);
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
private static string Sanitize(string? name)
|
||||
{
|
||||
var file = Path.GetFileName(name ?? "");
|
||||
if (string.IsNullOrWhiteSpace(file)) return "file";
|
||||
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).ToArray());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public sealed record AgentsFileResult(IReadOnlyList<RemoteAgentEntry> Agents, IR
|
||||
/// </summary>
|
||||
public static partial class AgentsFile
|
||||
{
|
||||
[GeneratedRegex("^[a-z0-9-]{1,40}$", RegexOptions.CultureInvariant)]
|
||||
[GeneratedRegex("^[a-z0-9-]{1,40}\\z", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex NamePattern();
|
||||
|
||||
public static string PathFor(string workspace) => Path.Combine(workspace, ".volcanic", "a2a", "agents.json");
|
||||
|
||||
@@ -47,8 +47,16 @@ public static class PendingFile
|
||||
var path = PathFor(workspace);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(list, Json));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
try
|
||||
{
|
||||
File.WriteAllText(temp, JsonSerializer.Serialize(list, Json));
|
||||
File.Move(temp, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +65,7 @@ public static class PendingFile
|
||||
var path = PathFor(workspace);
|
||||
try
|
||||
{
|
||||
return File.Exists(path) ? JsonSerializer.Deserialize<List<WatchRecord>>(File.ReadAllText(path), Json) ?? [] : [];
|
||||
return File.Exists(path) ? JsonSerializer.Deserialize<List<WatchRecord?>>(File.ReadAllText(path), Json)?.OfType<WatchRecord>().ToList() ?? [] : [];
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user