-
Notifications
You must be signed in to change notification settings - Fork 849
Description
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
- Enable an IChatClient to produce FunctionCallContent/FunctionResultContent that should not be re-invoked by callers - Support server-side function execution scenarios
- 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
- Allow multiple FunctionInvokingChatClients in a pipeline without duplicating work - Each function should only be invoked once
- 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 handledProposed 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
RemoveAlreadyHandledFunctionCallsmethod that uses aHashSet<string>for O(1)CallIdlookups - 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) FunctionInvokingChatClientsetsInvocationRequired = falseat the beginning ofProcessFunctionCallAsyncbefore any processing- Set for ANY response sent back, including NotFound and rejected cases
CopyFunctionCallsskips FCCs whereInvocationRequired == false- Always serialized to JSON to enable proper roundtrip (no
JsonIgnore) - Marked as experimental via
.jsonfile 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
JsonIgnoreis used with default value offalse, 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.Resultdirectly links to theFRC - Eliminates the common pattern of storing
FCC.Idto map toFRC.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 aHashSet<string>for O(1)CallIdlookups 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=falseis serialized and deserialized, it should not becometrue. RemoveJsonIgnoreattribute to enable proper roundtrip. - All response paths: If using
InvocationRequired, it should be set tofalseanywhere aFunctionResultContentis 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
FunctionCallContentobjects across iterations need to resetInvocationRequired = truebetween 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
FunctionInvokingChatClientinstances - validate function invoked only once - Three layered
FunctionInvokingChatClientinstances - validate function invoked only once - FCCs with
InvocationRequired=falsepassing 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
- Which approach should be taken - FRC matching or
InvocationRequiredproperty? - If FRC matching, should we expose a helper method for consumers doing manual function invocation?
- Should we consider the alternative of a
Resultproperty pointing to the FRC? - Does AGUI (or similar services) always return a matching FRC for server-side function calls?