Skip to content

Fix: Mark both TARC and TAResp FunctionCallContent as InformationalOnly after approval processing#7468

Open
westey-m wants to merge 2 commits intodotnet:mainfrom
westey-m:ficc-informationonly-set
Open

Fix: Mark both TARC and TAResp FunctionCallContent as InformationalOnly after approval processing#7468
westey-m wants to merge 2 commits intodotnet:mainfrom
westey-m:ficc-informationonly-set

Conversation

@westey-m
Copy link
Copy Markdown
Contributor

@westey-m westey-m commented Apr 15, 2026

Problem

Fixes microsoft/agent-framework#5189

FunctionInvokingChatClient.ExtractAndRemoveApprovalRequestsAndResponses throws InvalidOperationException when conversation history contains a ToolApprovalRequestContent (TARC) / ToolApprovalResponseContent (TAResp) pair whose FunctionCallContent objects have inconsistent InformationalOnly flags — a situation that reliably occurs after session serialization/deserialization.

Root Cause

FICC relies on shared object references between the FunctionCallContent (FCC) inside a TARC and the FCC inside the corresponding TAResp. When GenerateRejectedFunctionResults sets FCC.InformationalOnly = true, it only mutates the TAResp's FCC. Without serialization this works because TARC and TAResp share the same FCC object. After serialization, they are separate instances and the TARC's FCC retains InformationalOnly = false.

Turn-by-turn reproduction

Turn 1 — User sends a message. Model returns FunctionCallContent("call1"). FICC wraps it in ToolApprovalRequestContent and returns it. End-of-run persistence stores [user_msg, TARC(FCC)].

Session serialize → deserialize — The stored TARC now contains a new FCC object (FCC_D, InformationalOnly = false). The caller still holds the original FCC (FCC_OLD) from the response.

Turn 2 — User rejects the approval using the original TARC reference, creating TAResp(FCC_OLD). Messages sent to FICC:

Content FCC Object InformationalOnly
TARC (from deserialized history) FCC_D false
TAResp (from user input) FCC_OLD false

FICC processes successfully — both match the InformationalOnly: false pattern. GenerateRejectedFunctionResults sets FCC_OLD.InformationalOnly = true. But FCC_D is a different object and is NOT mutated. Model retries → returns new FCC("call2") → wrapped in TARC2.

End-of-run persistence stores:

Content FCC InformationalOnly
TARC(call1) FCC_D false (never mutated!)
TAResp(call1) FCC_OLD true (mutated)
FRC(call1, "rejected")
TARC2(call2) new FCC false

Turn 3 — User rejects call2. FICC's ExtractAndRemoveApprovalRequestsAndResponses processes history:

  1. TARC(FCC_D, InformationalOnly=false)matches pattern → adds "call1" to approvalRequestCallIds
  2. TAResp(FCC_OLD, InformationalOnly=true)SKIPPED (doesn't match InformationalOnly: false guard)
  3. TARC2(call2, InformationalOnly=false) → matches → adds "call2"
  4. TAResp2(call2, InformationalOnly=false) → matches → removes "call2"

Result: approvalRequestCallIds = {"call1"} — validation throws:

InvalidOperationException: ToolApprovalRequestContent found with FunctionCall.CallId(s) 'call1' 
that have no matching ToolApprovalResponseContent.

Fix

Mark both the request and response FunctionCallContent as InformationalOnly = true at every approval processing point, ensuring consistency regardless of object identity:

  1. ApprovalResultWithRequestMessage — Now stores the ToolApprovalRequestContent alongside the response, exposing both ResponseFunctionCallContent and RequestFunctionCallContent.

  2. ExtractAndRemoveApprovalRequestsAndResponses — The approval request dictionary now stores the TARC content alongside the message, and passes it through to ApprovalResultWithRequestMessage.Request.

  3. GenerateRejectedFunctionResults — Marks both ResponseFunctionCallContent and RequestFunctionCallContent as InformationalOnly = true.

  4. InvokeApprovedFunctionApprovalResponsesAsync — Also marks the request FCC for the approved path, since the same identity split exists after serialization.

  5. TestsCloneInput now deep-clones FunctionCallContent, ToolApprovalRequestContent, and ToolApprovalResponseContent, simulating the serialization effect (separate FCC instances) so existing tests exercise the previously broken codepath.

CC @stephentoub

@westey-m westey-m requested a review from a team as a code owner April 15, 2026 16:36
Copilot AI review requested due to automatic review settings April 15, 2026 16:36
@github-actions github-actions bot added the area-ai Microsoft.Extensions.AI libraries label Apr 15, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an approval-processing edge case in FunctionInvokingChatClient where ToolApprovalRequestContent (TARC) and ToolApprovalResponseContent (TAResp) can end up with inconsistent FunctionCallContent.InformationalOnly values after serialization/deserialization, leading to validation failures.

Changes:

  • Track the original ToolApprovalRequestContent alongside approval responses so both request/response FunctionCallContent instances can be updated consistently.
  • Mark both request and response FunctionCallContent as InformationalOnly=true when handling approvals/rejections to avoid mismatches across serialization boundaries.
  • Add a regression test covering the “separate FCC instances with same CallId” rejection scenario.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs Carries request content through approval processing and marks both request/response FCCs as informational after approval handling.
test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs Adds a test ensuring rejection processing marks InformationalOnly on both request and response FCC instances.

// We mark both the response and request FunctionCallContent to ensure consistency
// across serialization boundaries where they may be separate object instances.
m.ResponseFunctionCallContent.InformationalOnly = true;
_ = m.RequestFunctionCallContent?.InformationalOnly = true;
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m.RequestFunctionCallContent?.InformationalOnly = true uses the null-conditional operator on the left-hand side of an assignment, which is not valid C# and will fail to compile. Update this to an explicit null check (e.g., store the value in a local and set the property when non-null).

Suggested change
_ = m.RequestFunctionCallContent?.InformationalOnly = true;
var requestFunctionCallContent = m.RequestFunctionCallContent;
if (requestFunctionCallContent is not null)
{
requestFunctionCallContent.InformationalOnly = true;
}

Copilot uses AI. Check for mistakes.
// across serialization boundaries where they may be separate object instances.
foreach (var approval in notInvokedApprovals)
{
_ = approval.RequestFunctionCallContent?.InformationalOnly = true;
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approval.RequestFunctionCallContent?.InformationalOnly = true uses the null-conditional operator on the left-hand side of an assignment, which is not valid C# and will fail to compile. Use an explicit null check before setting InformationalOnly.

Suggested change
_ = approval.RequestFunctionCallContent?.InformationalOnly = true;
if (approval.RequestFunctionCallContent is not null)
{
approval.RequestFunctionCallContent.InformationalOnly = true;
}

Copilot uses AI. Check for mistakes.
…nctionInvokingChatClientApprovalsTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
_ = approvalRequestCallIds?.Remove(tarc.ToolCall.CallId);
(allApprovalResponses ??= []).Add(tarc);
break;

Copy link
Copy Markdown
Member

@jozkee jozkee Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix prevents the inconsistency from being created going forward, do we also want to handle stored conversations serialized before the fix? With something like this:

Suggested change
case ToolApprovalResponseContent tarc when tarc.ToolCall is FunctionCallContent { InformationalOnly: true }:
// Remove from validation set to handle sessions serialized before the fix
// for https://github.com/dotnet/extensions/pull/7468.
_ = approvalRequestCallIds?.Remove(tarc.ToolCall.CallId);
goto default;

This should also allow us to add a test with

var requestFcc = new FunctionCallContent("callId1", "Func1");
var responseFcc = new FunctionCallContent("callId1", "Func1") { InformationalOnly = true };

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-ai Microsoft.Extensions.AI libraries

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.NET: [Bug]: ToolApprovalRequestContent / ToolApprovalResponseContent reconciliation failure after Session Serialization

4 participants