Skip to content

FunctionInvokingChatClient should not re-invoke already-handled FunctionCallContent #7204

@mattleibow

Description

@mattleibow

Summary

FunctionInvokingChatClient currently attempts to invoke every FunctionCallContent (FCC) it encounters, even when the function call has already been handled. This breaks important scenarios where function calls have already been processed upstream.

Goals

  1. Enable an IChatClient to produce FunctionCallContent/FunctionResultContent that should not be re-invoked by callers - Support server-side function execution scenarios
  2. Allow callers to distinguish whether an FCC is informative or a request - Today when a consumer gets back an FCC they don't know if it's been handled, and need to look for a corresponding FRC to determine whether it should be handled or not
  3. Allow multiple FunctionInvokingChatClients in a pipeline without duplicating work - Each function should only be invoked once
  4. Maybe allow FunctionCallContent/FunctionResultContent (or derivations) to represent server-side invocations

Problem Description

The current implementation of FunctionInvokingChatClient does not check whether a function call has already been handled before invoking it. This causes problems in several scenarios.

Scenario 1: Multiple FunctionInvokingChatClients in the Pipeline

When there are multiple FunctionInvokingChatClient instances in an IChatClient pipeline, the same function may be invoked multiple times:

OuterFunctionInvokingChatClient
    └── InnerFunctionInvokingChatClient
            └── LLM Provider

If the inner client handles a function call and returns both the FunctionCallContent and FunctionResultContent, the outer client will incorrectly try to invoke the function again.

Scenario 2: Server-Side Function Execution

Some IChatClient implementations handle function invocation internally (server-side) and return both FunctionCallContent and FunctionResultContent to indicate what was done—not what needs to be done by the caller. In this case, wrapping such a client with FunctionInvokingChatClient causes duplicate invocations.

Scenario 3: Chat History Reuse

When chat history containing function calls is reused across multiple requests, FunctionInvokingChatClient may attempt to re-invoke functions that were already processed in a previous request.

Example

// Inner client returns both FCC and FRC (e.g., it already handled the function call)
var response = new ChatResponse([
    new ChatMessage(ChatRole.Assistant, [
        new FunctionCallContent("callId1", "Func1"),
        new FunctionResultContent("callId1", result: "Already handled by inner client")
    ])
]);

// FunctionInvokingChatClient currently tries to invoke Func1 again
// instead of recognizing it was already handled

Proposed Solutions

Two approaches have been explored:

Approach 1: Check for Matching FunctionResultContent

Look for a corresponding FunctionResultContent with the same CallId in the response. If found, consider the FCC as already handled.

Implementation:

  • Add RemoveAlreadyHandledFunctionCalls method that uses a HashSet<string> for O(1) CallId lookups
  • Filter FCCs after copying them from the response (non-streaming path)
  • Collect all updates before filtering/converting to approval requests in streaming scenarios (FRCs may appear later in the stream)
  • Skip converting FCCs to approval requests if they already have matching FRCs

Pros:

  • No new API surface on FunctionCallContent
  • Uses existing relationship between FCC and FRC via CallId
  • Aligned with how consumers would naturally check for handled calls

Cons:

  • Fairly complex, especially in streaming scenarios where FCC may be received before FRC
  • FCC and FRC are typically spread across multiple messages even in non-streaming
  • Any consumer handling function invocation manually would need similar logic (could expose a helper)
  • Requires mandating that any handled FCC must always have a paired FRC

Approach 2: Add InvocationRequired Property

Add a bool InvocationRequired property to FunctionCallContent that defaults to true and is set to false when the function has been processed.

Implementation:

  • Property defaults to true (requires invocation)
  • FunctionInvokingChatClient sets InvocationRequired = false at the beginning of ProcessFunctionCallAsync before any processing
  • Set for ANY response sent back, including NotFound and rejected cases
  • CopyFunctionCalls skips FCCs where InvocationRequired == false
  • Always serialized to JSON to enable proper roundtrip (no JsonIgnore)
  • Marked as experimental via .json file entry

Pros:

  • Simpler for consumers to check a single property
  • No need to search for matching FRCs across messages
  • Works naturally with multiple FICC instances in pipeline
  • Explicit signal of intent rather than implicit relationship

Cons:

  • Adds new API surface to FunctionCallContent
  • JSON roundtrip needs careful handling (if JsonIgnore is used with default value of false, serializing and deserializing would lose the value)
  • Adds a serializable boolean with no specific pointer/hint to the corresponding FRC

Alternative Suggestion from Community

Add a nullable [JsonIgnore] Result property on FunctionCallContent that points to the corresponding FunctionResultContent:

  • Makes API usage more intuitive - FCC.Result directly links to the FRC
  • Eliminates the common pattern of storing FCC.Id to map to FRC.CallId
  • Downstream components could check if (FCC.Result != null) to determine if already invoked
  • Aligns with the assumption that we will always have an FRC for invoked FCCs

Technical Considerations

  • Algorithmic complexity: Avoid O(N²) algorithms when processing messages and AIContent. Using a HashSet<string> for O(1) CallId lookups is recommended for Approach 1.
  • Streaming path complexity: In streaming scenarios, FRCs may appear in separate updates after their corresponding FCCs, requiring collection of all updates before filtering.
  • JSON serialization: Must handle roundtrip correctly - if InvocationRequired=false is serialized and deserialized, it should not become true. Remove JsonIgnore attribute to enable proper roundtrip.
  • All response paths: If using InvocationRequired, it should be set to false anywhere a FunctionResultContent is created, including:
    • Function not found (NotFound status)
    • Function invocation rejected (approval flow)
    • Function execution completed (RanToCompletion)
    • Function execution failed (Exception)
  • Test plan reuse: Tests that reuse FunctionCallContent objects across iterations need to reset InvocationRequired = true between runs.

Test Coverage Needed

The fix should include comprehensive tests covering:

Non-Streaming Scenarios

  • Inner client producing both FCC and FRC in same message
  • FCC and FRC in separate messages from inner client
  • Multiple FCCs - some with FRCs, some without (partial matches requiring invocation for the ones without)

Streaming Scenarios

  • FCC and FRC in same streaming update
  • FCC and FRC in separate streaming updates (FRC appears later)
  • Complex streaming with mixed content (text + FCC + FRC + more text)

Pipeline Scenarios

  • Two layered FunctionInvokingChatClient instances - validate function invoked only once
  • Three layered FunctionInvokingChatClient instances - validate function invoked only once
  • FCCs with InvocationRequired=false passing through outer client unprocessed

Approval Flow Scenarios

  • Approval-required functions with already-handled FCCs should skip approval request
  • Approval-required functions that still need invocation should request approval

Serialization Scenarios

  • JSON roundtrip for InvocationRequired = true
  • JSON roundtrip for InvocationRequired = false
  • Deserialization without the property defaults to true

Related PRs

PR Approach Status Notes
#7011 FRC matching Closed Initial attempt, closed as "needs more thought"
#7126 InvocationRequired property Closed Alternative approach, approved by @ericstj but closed pending design discussion
#7141 FRC matching Open Continued work on FRC matching approach, currently has * NO MERGE * label

Open Questions

  1. Which approach should be taken - FRC matching or InvocationRequired property?
  2. If FRC matching, should we expose a helper method for consumers doing manual function invocation?
  3. Should we consider the alternative of a Result property pointing to the FRC?
  4. Does AGUI (or similar services) always return a matching FRC for server-side function calls?

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI librariesuntriaged

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions