Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 195 additions & 19 deletions sdks/dotnet/src/AGUI.Server/RunAgentInputExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,41 @@ public static ChatRequestContext ToChatRequestContext(

// Translate AG-UI Resume entries into MEAI content on the message list so the
// inner pipeline (custom IChatClient, FICC, etc.) sees standard MEAI types.
// Tool-approval-shaped resume payloads (with a `toolCall` field) become a
// ToolApprovalRequestContent + ToolApprovalResponseContent pair so
// FunctionInvokingChatClient resumes the tool naturally; everything else becomes
// a generic InterruptResponseContent.
// Tool-approval-shaped resume payloads become a ToolApprovalRequestContent +
// ToolApprovalResponseContent pair so FunctionInvokingChatClient resumes the tool
// naturally; everything else becomes a generic InterruptResponseContent.
//
// The approval interrupt advertises `{"required":["approved"]}` as its response
// schema, so a payload carrying only `approved` is a valid answer and the tool call
// it answers is recovered from the run's messages (which a resuming run carries by
// spec). A payload that also echoes the full `toolCall` is still accepted.
if (input.Resume is { Count: > 0 } resumeEntries)
{
var originalCallsById = new Dictionary<string, FunctionCallContent>(StringComparer.Ordinal);
foreach (var message in messages)
{
foreach (var content in message.Contents)
{
if (content is FunctionCallContent call)
{
originalCallsById[call.CallId] = call;
}
}
}

var pendingApprovalCalls = CollectPendingApprovalCalls(messages, clientToolNames);

// Calls already answered by an earlier entry in this batch. A later entry for the same
// call, whatever its shape, stays a generic interrupt response so the tool runs once.
var claimedCallIds = new HashSet<string>(StringComparer.Ordinal);

// Entries that name their call (an echo or a toolCallId) are resolved first, so a bare
// entry never competes with them whatever order the client sent them in.
var genericResponses = new List<AIContent>(resumeEntries.Count);
foreach (var resume in resumeEntries)
foreach (var resume in resumeEntries.OrderBy(r => IsBareApprovalPayload(r) ? 1 : 0))
{
if (TryDecodeToolApprovalResume(resume, jsonSerializerOptions,
originalCallsById, pendingApprovalCalls, claimedCallIds,
out var approvalRequest, out var approvalResponse))
{
messages.Add(new ChatMessage(ChatRole.Assistant, [approvalRequest!]));
Expand Down Expand Up @@ -172,47 +197,198 @@ public static bool TryGetRunAgentState<T>(this ChatOptions options, JsonTypeInfo
return true;
}

/// <summary>
/// Collects the tool calls in <paramref name="messages"/> that are still waiting on an
/// approval answer: a call with no <see cref="FunctionResultContent"/> and no
/// <see cref="ToolApprovalResponseContent"/> naming it. Client tools are excluded because the
/// client owns and gates them, so the emit side never raises an approval interrupt for one.
/// </summary>
private static List<FunctionCallContent> CollectPendingApprovalCalls(
List<ChatMessage> messages,
HashSet<string> clientToolNames)
{
var answeredCallIds = new HashSet<string>(StringComparer.Ordinal);
var candidates = new List<FunctionCallContent>();

foreach (var message in messages)
{
foreach (var content in message.Contents)
{
switch (content)
{
case FunctionCallContent call when !clientToolNames.Contains(call.Name):
candidates.Add(call);
break;
case FunctionResultContent result:
answeredCallIds.Add(result.CallId);
break;
case ToolApprovalResponseContent { ToolCall: FunctionCallContent answered }:
answeredCallIds.Add(answered.CallId);
break;
default:
break;
}
}
}

if (answeredCallIds.Count > 0)
{
candidates.RemoveAll(call => answeredCallIds.Contains(call.CallId));
}

return candidates;
}

private static bool TryDecodeToolApprovalResume(
AGUIResume resume,
JsonSerializerOptions jsonSerializerOptions,
Dictionary<string, FunctionCallContent> originalCallsById,
List<FunctionCallContent> pendingApprovalCalls,
HashSet<string> claimedCallIds,
out ToolApprovalRequestContent? request,
out ToolApprovalResponseContent? response)
{
request = null;
response = null;

if (resume.Payload is not { ValueKind: JsonValueKind.Object } element
|| !element.TryGetProperty("toolCall", out _))
if (resume.Payload is not { ValueKind: JsonValueKind.Object } element)
{
return false;
}

AGUIToolApprovalResumePayload? payload;
try
bool approved;
FunctionCallContent fcc;

if (element.TryGetProperty("toolCall", out _))
{
payload = (AGUIToolApprovalResumePayload?)element.Deserialize(
jsonSerializerOptions.GetTypeInfo(typeof(AGUIToolApprovalResumePayload)));
AGUIToolApprovalResumePayload? payload;
try
{
payload = (AGUIToolApprovalResumePayload?)element.Deserialize(
jsonSerializerOptions.GetTypeInfo(typeof(AGUIToolApprovalResumePayload)));
}
catch (JsonException)
{
return false;
}

if (payload?.ToolCall is null)
{
return false;
}

approved = payload.Approved;
fcc = new FunctionCallContent(
callId: payload.ToolCall.CallId ?? string.Empty,
name: payload.ToolCall.Name ?? string.Empty,
arguments: payload.ToolCall.Arguments);
}
catch (JsonException)
else
{
return false;
// No echo. Only a payload shaped like the advertised approval schema is treated as
// an approval answer; anything else stays a generic interrupt response.
if (!element.TryGetProperty("approved", out var approvedElement)
|| approvedElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return false;
}

if (ResolvePendingApprovalCall(resume, element, originalCallsById, pendingApprovalCalls)
is not { } resolved)
{
return false;
}

approved = approvedElement.GetBoolean();
fcc = resolved;
}

if (payload?.ToolCall is null)
// The first entry resolved for a call answers it; any later entry for it approves nothing.
if (!claimedCallIds.Add(fcc.CallId))
{
return false;
}

var fcc = new FunctionCallContent(
callId: payload.ToolCall.CallId ?? string.Empty,
name: payload.ToolCall.Name ?? string.Empty,
arguments: payload.ToolCall.Arguments);
pendingApprovalCalls.RemoveAll(call => string.Equals(call.CallId, fcc.CallId, StringComparison.Ordinal));

// An abandoned interrupt is never an approval, whatever the payload claims.
var cancelled = string.Equals(resume.Status, ResumeStatus.Cancelled, StringComparison.Ordinal);

request = new ToolApprovalRequestContent(resume.InterruptId, fcc);
response = new ToolApprovalResponseContent(resume.InterruptId, payload.Approved, fcc);
response = new ToolApprovalResponseContent(resume.InterruptId, approved && !cancelled, fcc);
return true;
}

private static bool IsBareApprovalPayload(AGUIResume resume) =>
resume.Payload is { ValueKind: JsonValueKind.Object } element
&& !element.TryGetProperty("toolCall", out _)
&& !element.TryGetProperty("toolCallId", out _);

/// <summary>
/// Recovers the tool call a <c>toolCall</c>-less approval resume answers from the run's
/// messages. The interrupt id is the approval request id FunctionInvokingChatClient minted for
/// the call (see <see cref="FiccApprovalRequestIdFor"/>), so it alone names the call; a
/// <c>toolCallId</c> in the payload, when present, must agree with it.
/// </summary>
/// <returns>
/// The recovered call, or <see langword="null"/> when the interrupt is not an approval
/// interrupt or its call is already answered (the entry then stays a generic interrupt response).
/// </returns>
/// <exception cref="InvalidOperationException">
/// The entry's <c>toolCallId</c> names a different call than its approval interrupt id, or the
/// run's messages do not carry the call.
/// </exception>
private static FunctionCallContent? ResolvePendingApprovalCall(
AGUIResume resume,
JsonElement element,
Dictionary<string, FunctionCallContent> originalCallsById,
List<FunctionCallContent> pendingApprovalCalls)
{
// An interrupt id that is not an approval id (another kind of interrupt) approves nothing,
// whatever its payload carries.
var approvalIdPrefix = FiccApprovalRequestIdFor(string.Empty);
if (string.IsNullOrEmpty(resume.InterruptId)
|| !resume.InterruptId.StartsWith(approvalIdPrefix, StringComparison.Ordinal))
{
return null;
}

var interruptCallId = resume.InterruptId[approvalIdPrefix.Length..];

if (element.TryGetProperty("toolCallId", out var toolCallIdElement)
&& toolCallIdElement.ValueKind is JsonValueKind.String
&& toolCallIdElement.GetString() is { Length: > 0 } toolCallId
&& !string.Equals(toolCallId, interruptCallId, StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Resume entry '{resume.InterruptId}' is the approval for tool call '{interruptCallId}', but its payload names tool call '{toolCallId}'.");
}

foreach (var pending in pendingApprovalCalls)
{
if (string.Equals(pending.CallId, interruptCallId, StringComparison.Ordinal))
{
return pending;
}
}

// A call already answered (resolved elsewhere, or a replayed resume) is not approved a
// second time, and a call the messages do not carry at all is rejected.
if (originalCallsById.ContainsKey(interruptCallId))
{
return null;
}

throw new InvalidOperationException(
$"Resume entry '{resume.InterruptId}' answers tool call '{interruptCallId}', which the run's messages do not carry.");
}

// FunctionInvokingChatClient mints the approval request id for a call in its private
// ComposeApprovalRequestId, and the server emits that request id as the interrupt id, so a
// bare resume entry is matched back to its call through this same id. Kept in one place
// because the format is FICC's, not ours.
private static string FiccApprovalRequestIdFor(string callId) => "ficc_" + callId;

/// <summary>
/// Configures <paramref name="chatOptions"/> for mixed server/client tool invocation.
/// On the first turn, wraps client tools in <see cref="ApprovalRequiredAIFunction"/> so FICC
Expand Down
Loading
Loading