feat(a2a): map one A2A task to one agent session run

This commit is contained in:
EugeneTes
2026-10-07 12:51:32 +00:00
parent 79079ea6ba
commit e932a44877
2 changed files with 206 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
using System.Diagnostics;
using A2A;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volcanic.AgenticCore.A2A.Server.Callers;
using Volcanic.AgenticCore.Abstractions.Processes;
namespace Volcanic.AgenticCore.A2A.Server.Agent;
/// <summary>
/// One task = one run of the caller context's session. Ends completed (final text as artifact <c>response</c>),
/// input-required (the agent called <c>request_input</c>), failed (model error, hop limit, bad file) or canceled.
/// </summary>
internal sealed class A2AAgentHandler(
A2ACaller caller, A2ASessionPool pool, IOptions<A2AHostOptions> options, ISecretRedactor redactor, HttpClient http,
TimeProvider time, ILogger<A2AAgentHandler> logger) : IAgentHandler
{
public async Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
var updater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId);
var started = Stopwatch.GetTimestamp();
if (context.Task is null) await updater.SubmitAsync(cancellationToken).ConfigureAwait(false);
await updater.StartWorkAsync(null, cancellationToken).ConfigureAwait(false);
var hops = Hops.Read(context.Metadata, context.Message.Metadata);
if (hops > options.Value.MaxHops)
{
await updater.FailAsync(A2AText.Agent($"hop limit reached ({hops} > {options.Value.MaxHops})"), CancellationToken.None).ConfigureAwait(false);
Log(context, "failed (hop limit)", started);
return;
}
A2ASessionPool.Lease lease;
try
{
lease = await pool.AcquireAsync(caller, context.ContextId, context.TaskId, cancellationToken).ConfigureAwait(false);
}
catch (A2AException ex)
{
await updater.FailAsync(A2AText.Agent(ex.Message), CancellationToken.None).ConfigureAwait(false);
Log(context, "failed (busy)", started);
return;
}
using (lease)
{
string prompt;
try
{
prompt = await A2APrompt.BuildAsync(caller, context.Message, options.Value.MaxFileBytes, http, time, cancellationToken).ConfigureAwait(false);
}
catch (A2APromptException ex)
{
await updater.FailAsync(A2AText.Agent(ex.Message), CancellationToken.None).ConfigureAwait(false);
Log(context, "failed (file)", started);
return;
}
var mapper = new A2ARunMapper(updater, redactor, cancellationToken);
lease.Extension.BeginRun(hops);
lease.Channel.Bind(updater);
try
{
using var subscription = lease.Session.Subscribe(mapper.OnEvent);
using var abort = cancellationToken.Register(() => _ = lease.Session.AbortAsync());
await lease.Session.PromptAsync(prompt, cancellationToken: CancellationToken.None).ConfigureAwait(false);
}
finally
{
lease.Channel.Bind(null);
await mapper.DisposeAsync().ConfigureAwait(false);
}
string state;
if (cancellationToken.IsCancellationRequested || mapper.Aborted)
{
await updater.CancelAsync(CancellationToken.None).ConfigureAwait(false);
state = "canceled";
}
else if (mapper.Error is { } error)
{
await updater.FailAsync(A2AText.Agent(error), CancellationToken.None).ConfigureAwait(false);
state = "failed";
}
else
{
if (mapper.FinalText is { } text)
await updater.AddArtifactAsync([Part.FromText(text)], A2ARunMapper.ResponseArtifactId, "response", null,
lastChunk: true, append: false, CancellationToken.None).ConfigureAwait(false);
if (lease.Extension.PendingQuestion is { } question)
{
await updater.RequireInputAsync(A2AText.Agent(question), CancellationToken.None).ConfigureAwait(false);
state = "input-required";
}
else
{
await updater.CompleteAsync(null, CancellationToken.None).ConfigureAwait(false);
state = "completed";
}
}
Log(context, state, started);
}
}
public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken)
{
await pool.AbortAsync(context.TaskId).ConfigureAwait(false);
await new TaskUpdater(eventQueue, context.TaskId, context.ContextId).CancelAsync(cancellationToken).ConfigureAwait(false);
}
private void Log(RequestContext context, string state, long started) =>
logger.LogInformation("a2a task {TaskId} owner={Owner} caller={Caller} state={State} in {Duration}",
context.TaskId, caller.OwnerId, caller.Name, state, Stopwatch.GetElapsedTime(started));
}

View File

@@ -0,0 +1,92 @@
using System.Threading.Channels;
using A2A;
using Volcanic.AgenticCore.Abstractions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
namespace Volcanic.AgenticCore.A2A.Server.Agent;
/// <summary>
/// Maps session events (raised synchronously on the loop) to task updates, written in order by a pump so the loop never
/// waits. Text deltas append to artifact <c>response</c>; tool starts become working-status messages. Tracks how the run
/// ended: the last assistant message decides (a retried failure followed by success counts as success).
/// </summary>
internal sealed class A2ARunMapper : IAsyncDisposable
{
public const string ResponseArtifactId = "response";
private readonly Channel<Func<ValueTask>> _work = Channel.CreateUnbounded<Func<ValueTask>>(new UnboundedChannelOptions { SingleReader = true });
private readonly TaskUpdater _updater;
private readonly ISecretRedactor _redactor;
private readonly CancellationToken _ct;
private readonly Task _pump;
public A2ARunMapper(TaskUpdater updater, ISecretRedactor redactor, CancellationToken cancellationToken)
{
_updater = updater;
_redactor = redactor;
_ct = cancellationToken;
_pump = Task.Run(PumpAsync, CancellationToken.None);
}
public string? FinalText { get; private set; }
public string? Error { get; private set; }
public bool Aborted { get; private set; }
public void OnEvent(AgentSessionEvent e)
{
switch (e)
{
case AgentSessionEvent.MessageUpdate { AssistantMessageEvent: AssistantMessageEvent.TextDelta delta }:
Post(() => _updater.AddArtifactAsync([Part.FromText(delta.Delta)], ResponseArtifactId, "response", null,
lastChunk: false, append: true, _ct));
break;
case AgentSessionEvent.MessageEnd { Message: AssistantMessage message }:
if (message.StopReason == StopReason.Aborted)
{
Aborted = true;
}
else if (message.IsFailure)
{
Error = _redactor.Redact(message.ErrorMessage ?? "The model call failed.");
}
else
{
Error = null;
if (!string.IsNullOrWhiteSpace(message.Text)) FinalText = message.Text;
}
break;
case AgentSessionEvent.ToolExecutionStart start:
Post(() => _updater.StartWorkAsync(A2AText.Agent($"using {start.ToolName}"), _ct));
break;
}
}
private void Post(Func<ValueTask> work) => _work.Writer.TryWrite(work);
private async Task PumpAsync()
{
await foreach (var work in _work.Reader.ReadAllAsync().ConfigureAwait(false))
{
try { await work().ConfigureAwait(false); }
catch (OperationCanceledException) { }
catch (InvalidOperationException) { } // queue already completed (task cancelled)
}
}
/// <summary>Wait until every posted update is written.</summary>
public async ValueTask DisposeAsync()
{
_work.Writer.TryComplete();
await _pump.ConfigureAwait(false);
}
}
internal static class A2AText
{
public static global::A2A.Message Agent(string text) => new()
{
Role = Role.Agent, MessageId = Guid.NewGuid().ToString("N"), Parts = [Part.FromText(text)],
};
}