Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,14 @@ private bool TryGetSession([NotNullWhen(true)] out AgentSession? session)
}

/// <summary>
/// Rewrites the inbound messages so that each <see cref="ToolApprovalResponseContent"/> is bound to a known
/// <see cref="ToolApprovalRequestContent"/>, with its tool call rebound to the request's call when it differs.
/// A response with no known request is removed so a forged approval cannot drive execution. Approval requests
/// are left untouched: a request present in the message history is itself the pairing authority.
/// Validates inbound <see cref="ToolApprovalResponseContent"/> against known requests and rebinds matched
/// responses to the model-originated tool call. Responses with no known request are dropped.
/// </summary>
/// <remarks>
/// Pending session entries are consumed only when a matching approval response is bound in this turn.
/// An unrelated user message must not clear pending approvals (#7872); otherwise a restored session can
/// lose binding authority while a dangling <see cref="FunctionCallContent"/> remains in history.
/// </remarks>
private IEnumerable<ChatMessage> ValidateInboundApprovalResponses(IEnumerable<ChatMessage> messages, AgentSession session)
{
var messageList = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
Expand All @@ -170,12 +173,11 @@ private IEnumerable<ChatMessage> ValidateInboundApprovalResponses(IEnumerable<Ch
// 2. Requests already present in the current message history (covers replayed history and approvals
// generated internally, such as the mixed server/client tool invocation used by AG-UI hosting).
// A response is honored only when its request id is known, and it is rebound to the known request's call.
var knownRequests = LoadPendingApprovalRequestLookup(session);

// Pending state only needs to bridge a single turn; consume it now.
if (knownRequests.Count > 0)
var pendingFromBag = LoadPendingApprovalRequests(session);
var knownRequests = new Dictionary<string, ToolApprovalRequestContent>(pendingFromBag.Count, StringComparer.Ordinal);
foreach (var request in pendingFromBag)
{
session.StateBag.TryRemoveValue(StateBagKey);
knownRequests[request.RequestId] = request;
}

bool hasResponse = false;
Expand All @@ -196,6 +198,7 @@ private IEnumerable<ChatMessage> ValidateInboundApprovalResponses(IEnumerable<Ch
}

// Only approval responses are rewritten; if there are none there is nothing to bind or drop.
// Leave pending session state intact so an unrelated user turn cannot silently abandon approvals.
if (!hasResponse)
{
return messageList;
Expand Down Expand Up @@ -235,6 +238,19 @@ private IEnumerable<ChatMessage> ValidateInboundApprovalResponses(IEnumerable<Ch
}
}

// Persist only bag-originated requests that were not answered in this turn. History-only known
// requests are not written into the session bag.
var remainingPending = new List<ToolApprovalRequestContent>(pendingFromBag.Count);
foreach (var request in pendingFromBag)
{
if (knownRequests.ContainsKey(request.RequestId))
{
remainingPending.Add(request);
}
}

SavePendingApprovalRequests(remainingPending, session);

return result ?? messageList;
}

Expand Down Expand Up @@ -376,18 +392,6 @@ private static bool ArgumentsEquivalent(IDictionary<string, object?>? responseAr
return true;
}

private static Dictionary<string, ToolApprovalRequestContent> LoadPendingApprovalRequestLookup(AgentSession session)
{
var pendingRequests = LoadPendingApprovalRequests(session);
var byRequestId = new Dictionary<string, ToolApprovalRequestContent>(pendingRequests.Count, StringComparer.Ordinal);
foreach (var request in pendingRequests)
{
byRequestId[request.RequestId] = request;
}

return byRequestId;
}

/// <summary>
/// Records model-originated <see cref="ToolApprovalRequestContent"/> items found in the response messages into
/// the session so they can be matched against the caller's approval responses on the next request.
Expand Down Expand Up @@ -445,10 +449,10 @@ private void MergePendingApprovalRequests(List<ToolApprovalRequestContent> emitt
}

/// <summary>
/// Creates a snapshot of an approval request so a later mutation of the caller-visible instance
/// (for example changing the tool call arguments) cannot alter the recorded request used for binding.
/// Creates a deep snapshot of an approval request for durable storage or public enumeration so callers
/// cannot mutate the model-originated binding authority through shared <see cref="FunctionCallContent.Arguments"/>.
/// </summary>
private static ToolApprovalRequestContent SnapshotRequest(ToolApprovalRequestContent request)
internal static ToolApprovalRequestContent SnapshotRequest(ToolApprovalRequestContent request)
{
if (request.ToolCall is FunctionCallContent functionCall)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;

namespace Microsoft.Agents.AI;

/// <summary>
/// Provides extension methods for reading and closing human-in-the-loop tool approval state on an
/// <see cref="AgentSession"/>.
/// </summary>
/// <remarks>
/// <para>
/// When a run surfaces <see cref="ToolApprovalRequestContent"/> and is cancelled, refreshed, or otherwise
/// interrupted before the host submits a matching <see cref="ToolApprovalResponseContent"/>, the framework
/// retains those requests in session state. Durable hosts can enumerate them after restore, re-surface UI,
/// or drain them as explicit rejections before accepting a new normal user turn (#7862, #7872).
/// </para>
/// <para>
/// Rejection helpers return response content the host should send on the next agent run so
/// <see cref="FunctionInvokingChatClient"/> can emit terminal <see cref="FunctionResultContent"/> and close
/// the tool lifecycle. Pending bag entries stay until those responses are validated and consumed on that run;
/// clearing the bag first would drop the only binding authority on restore paths where message history has a
/// dangling <see cref="FunctionCallContent"/> without a matching approval request.
/// </para>
/// </remarks>
public static class ToolApprovalAgentSessionExtensions
{
/// <summary>
/// Attempts to retrieve the tool approval requests that the framework has surfaced for the specified session
/// and that have not yet been answered with a matching <see cref="ToolApprovalResponseContent"/>.
/// </summary>
/// <param name="session">The agent session to read pending approval requests from.</param>
/// <param name="requests">
/// When this method returns, contains deep snapshots of the pending approval requests if any were found;
/// otherwise, <see langword="null"/>.
/// </param>
/// <returns>
/// <see langword="true"/> if at least one pending approval request was found; otherwise,
/// <see langword="false"/>.
/// </returns>
public static bool TryGetPendingToolApprovalRequests(
this AgentSession session,
[NotNullWhen(true)] out IReadOnlyList<ToolApprovalRequestContent>? requests)
{
_ = Throw.IfNull(session);

if (session.StateBag.TryGetValue<List<ToolApprovalRequestContent>>(
ApprovalResponseBindingChatClient.StateBagKey,
out var pending,
AgentJsonUtilities.DefaultOptions)
&& pending is { Count: > 0 })
{
// Deep-snapshot each request so hosts cannot mutate FunctionCallContent.Arguments on the
// model-originated binding authority retained in the session bag.
var snapshots = new List<ToolApprovalRequestContent>(pending.Count);
foreach (var request in pending)
{
snapshots.Add(ApprovalResponseBindingChatClient.SnapshotRequest(request));
}

requests = snapshots;
return true;
}

requests = null;
return false;
}

/// <summary>
/// Creates rejection responses for every pending tool approval request on the session.
/// </summary>
/// <param name="session">The agent session whose pending approvals should be closed.</param>
/// <param name="reason">
/// An optional reason recorded on each rejection (for example cancellation or host-initiated drain).
/// </param>
/// <returns>
/// The rejection responses to send on the next agent run so the function loop can emit terminal
/// <see cref="FunctionResultContent"/>. Empty when nothing was pending. Pending bag entries remain until
/// those responses are bound and consumed on that run.
/// </returns>
public static IReadOnlyList<ToolApprovalResponseContent> CreatePendingApprovalRejections(
this AgentSession session,
string? reason = null)
{
_ = Throw.IfNull(session);

if (!session.TryGetPendingToolApprovalRequests(out var pending))
{
return [];
}

var responses = new List<ToolApprovalResponseContent>(pending.Count);
foreach (var request in pending)
{
responses.Add(request.CreateResponse(approved: false, reason));
}

// Do not clear the bag here. On restore, history may only contain a dangling FunctionCallContent;
// ValidateInboundApprovalResponses needs these entries to honor the returned rejections.
return responses;
}

/// <summary>
/// Removes all pending tool approval requests from the session without creating rejection responses.
/// </summary>
/// <param name="session">The agent session to clear.</param>
/// <returns>
/// <see langword="true"/> if pending approval state was present and removed; otherwise,
/// <see langword="false"/>.
/// </returns>
/// <remarks>
/// Prefer <see cref="CreatePendingApprovalRejections"/> when the host can still run the agent, so the
/// function loop can close each tool call with a terminal result. Use this only when discarding binding
/// authority intentionally without producing responses.
/// </remarks>
public static bool ClearPendingToolApprovalRequests(this AgentSession session)
{
_ = Throw.IfNull(session);
return session.StateBag.TryRemoveValue(ApprovalResponseBindingChatClient.StateBagKey);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,150 @@ public async Task GetResponseAsync_NoSession_PassesThroughUnvalidatedAsync()
Assert.Contains(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent);
}

[Fact]
public async Task GetResponseAsync_UnrelatedUserMessage_DoesNotConsumePendingEntryAsync()
{
// Arrange — an approval was surfaced, then the host sends a normal user message with the same session.
var session = new ChatClientAgentSession();
await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, new FunctionCallContent("call1", "toolA")));
Assert.True(session.TryGetPendingToolApprovalRequests(out _));

var capture = new Capture();
var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(capture));

// Act
await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, "unrelated follow-up")]);

// Assert — pending approval authority must survive (#7872); only matching responses consume it.
Assert.True(session.TryGetPendingToolApprovalRequests(out var pending));
Assert.Equal(RequestId, Assert.Single(pending!).RequestId);
Assert.DoesNotContain(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent);
}

[Fact]
public async Task TryGetPendingToolApprovalRequests_SurvivesSessionStateRoundTripAsync()
{
// Arrange — a run stops on an approval request, then the session is persisted and reloaded.
var session = new ChatClientAgentSession();
var call = new FunctionCallContent("call1", "get_weather", new Dictionary<string, object?> { ["location"] = "Beijing" });
await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, call));

var restored = new ChatClientAgentSession(
stateBag: AgentSessionStateBag.Deserialize(session.StateBag.Serialize()));

// Act
var found = restored.TryGetPendingToolApprovalRequests(out var pending);

// Assert — the host can discover the pending approval without reading private state bag keys.
Assert.True(found);
var request = Assert.Single(pending!);
Assert.Equal(RequestId, request.RequestId);
var pendingCall = Assert.IsType<FunctionCallContent>(request.ToolCall);
Assert.Equal("get_weather", pendingCall.Name);
Assert.Equal("call1", pendingCall.CallId);
}

[Fact]
public async Task TryGetPendingToolApprovalRequests_AfterResponseIsConsumed_ReturnsFalseAsync()
{
// Arrange — record a request, then answer it.
var session = new ChatClientAgentSession();
await RecordRequestAsync(session, new ToolApprovalRequestContent(RequestId, new FunctionCallContent("call1", "toolA")));
Assert.True(session.TryGetPendingToolApprovalRequests(out _));

var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(new Capture()));
var approval = new ToolApprovalResponseContent(RequestId, approved: true, new FunctionCallContent("call1", "toolA"));

// Act
await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [approval])]);

// Assert — the answered request is no longer pending.
Assert.False(session.TryGetPendingToolApprovalRequests(out var pending));
Assert.Null(pending);
}

[Fact]
public void TryGetPendingToolApprovalRequests_NoApprovalState_ReturnsFalse()
{
Assert.False(new ChatClientAgentSession().TryGetPendingToolApprovalRequests(out var pending));
Assert.Null(pending);
}

[Fact]
public async Task TryGetPendingToolApprovalRequests_ReturnsDeepSnapshot_HostMutationDoesNotAffectBagAsync()
{
// Arrange
var session = new ChatClientAgentSession();
await RecordRequestAsync(
session,
new ToolApprovalRequestContent(
RequestId,
new FunctionCallContent("call1", "toolA", new Dictionary<string, object?> { ["amount"] = 1 })));

Assert.True(session.TryGetPendingToolApprovalRequests(out var pending));
var enumeratedCall = Assert.IsType<FunctionCallContent>(Assert.Single(pending!).ToolCall);

// Act — mutate the publicly enumerated instance.
enumeratedCall.Arguments!["amount"] = 9999999;

// Assert — bag snapshot used for binding is unchanged.
var capture = new Capture();
var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(capture));
var response = new ToolApprovalResponseContent(
RequestId,
approved: true,
new FunctionCallContent("call1", "toolA", new Dictionary<string, object?> { ["amount"] = 9999999 }));
await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [response])]);

var forwarded = capture.Messages!.SelectMany(m => m.Contents).OfType<ToolApprovalResponseContent>().Single();
Assert.Equal(1, Assert.IsType<FunctionCallContent>(forwarded.ToolCall).Arguments!["amount"]);
}

[Fact]
public async Task CreatePendingApprovalRejections_KeepsPendingUntilResponsesAreConsumedAsync()
{
// Arrange — restore-style session: pending in the bag, no approval request in inbound history.
var session = new ChatClientAgentSession();
await RecordRequestAsync(
session,
new ToolApprovalRequestContent(RequestId, new FunctionCallContent("call1", "toolA")));

// Act — host drains by creating rejections (bag must remain for binding on the next run).
var responses = session.CreatePendingApprovalRejections(reason: "host drain");

// Assert — rejections are ready, but binding authority is still present.
var rejection = Assert.Single(responses);
Assert.Equal(RequestId, rejection.RequestId);
Assert.False(rejection.Approved);
Assert.Equal("host drain", rejection.Reason);
Assert.True(session.TryGetPendingToolApprovalRequests(out _));

// Submitting the rejections consumes the bag so FICC can emit terminal results (#7872).
var capture = new Capture();
var decorator = new ApprovalResponseBindingChatClient(CreateCapturingChatClient(capture));
await RunAsync(decorator, session, [new ChatMessage(ChatRole.User, [.. responses])]);

Assert.Contains(capture.Messages!.SelectMany(m => m.Contents), c => c is ToolApprovalResponseContent { Approved: false });
Assert.False(session.TryGetPendingToolApprovalRequests(out _));
}

[Fact]
public void ClearPendingToolApprovalRequests_RemovesBagEntry()
{
var session = new ChatClientAgentSession();
session.StateBag.SetValue(
ApprovalResponseBindingChatClient.StateBagKey,
new List<ToolApprovalRequestContent>
{
new(RequestId, new FunctionCallContent("call1", "toolA")),
},
AgentJsonUtilities.DefaultOptions);

Assert.True(session.ClearPendingToolApprovalRequests());
Assert.False(session.TryGetPendingToolApprovalRequests(out _));
Assert.False(session.ClearPendingToolApprovalRequests());
}

private static async Task RecordRequestAsync(ChatClientAgentSession session, ToolApprovalRequestContent request)
{
var inner = CreateMockChatClient((_, _, _) =>
Expand Down
Loading