diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 7c506a7845b..b8a135b7e1d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -56,6 +56,16 @@ public FunctionCallContent(string callId, string name, IDictionary + /// Gets or sets a value indicating whether this function call requires invocation. + /// + /// + /// This property defaults to , indicating that the function call should be processed. + /// When set to , it indicates that the function has already been processed or is otherwise + /// purely informational and should be ignored by components that process function calls. + /// + public bool InvocationRequired { get; set; } = true; + /// /// Creates a new instance of parsing arguments using a specified encoding and parser. /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index ef1f2ebb496..e6ad6cc28b6 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1817,6 +1817,10 @@ "Member": "System.Exception? Microsoft.Extensions.AI.FunctionCallContent.Exception { get; set; }", "Stage": "Stable" }, + { + "Member": "bool Microsoft.Extensions.AI.FunctionCallContent.InvocationRequired { get; set; }", + "Stage": "Stable" + }, { "Member": "string Microsoft.Extensions.AI.FunctionCallContent.Name { get; }", "Stage": "Stable" diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index c88fe91fc17..cd449248867 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -828,7 +828,7 @@ private static bool CopyFunctionCalls( int count = content.Count; for (int i = 0; i < count; i++) { - if (content[i] is FunctionCallContent functionCall) + if (content[i] is FunctionCallContent functionCall && functionCall.InvocationRequired) { (functionCalls ??= []).Add(functionCall); any = true; @@ -1125,6 +1125,9 @@ private async Task ProcessFunctionCallAsync( { var callContent = callContents[functionCallIndex]; + // Mark the function call as no longer requiring invocation since we're handling it + callContent.InvocationRequired = false; + // Look up the AIFunction for the function call. If the requested function isn't available, send back an error. AIFunctionDeclaration? tool = FindTool(callContent.Name, options?.Tools, AdditionalTools); if (tool is not AIFunction aiFunction) @@ -1565,6 +1568,8 @@ private static bool CurrentActivityIsInvokeAgent result = $"{result} {m.Response.Reason}"; } + // Mark the function call as no longer requiring invocation since we're handling it (by rejecting it) + m.Response.FunctionCall.InvocationRequired = false; return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, result); }) : null; @@ -1703,7 +1708,7 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList { for (int i = 0; i < content.Count; i++) { - if (content[i] is FunctionCallContent fcc) + if (content[i] is FunctionCallContent fcc && fcc.InvocationRequired) { updatedContent ??= [.. content]; // Clone the list if we haven't already updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); @@ -1734,7 +1739,7 @@ private IList ReplaceFunctionCallsWithApprovalRequests( var content = messages[i].Contents; for (int j = 0; j < content.Count; j++) { - if (content[j] is FunctionCallContent functionCall) + if (content[j] is FunctionCallContent functionCall && functionCall.InvocationRequired) { (allFunctionCallContentIndices ??= []).Add((i, j)); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs index 85dd68f42c2..394728b7236 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs @@ -28,6 +28,7 @@ public void Constructor_PropsDefault() Assert.Null(c.Arguments); Assert.Null(c.Exception); + Assert.True(c.InvocationRequired); } [Fact] @@ -71,6 +72,88 @@ public void Constructor_PropsRoundtrip() Exception e = new(); c.Exception = e; Assert.Same(e, c.Exception); + + Assert.True(c.InvocationRequired); + c.InvocationRequired = false; + Assert.False(c.InvocationRequired); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InvocationRequired_Serialization(bool invocationRequired) + { + // Arrange + var sut = new FunctionCallContent("callId1", "functionName", new Dictionary { ["key"] = "value" }) + { + InvocationRequired = invocationRequired + }; + + // Act + var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); + + // Assert - InvocationRequired should always be in the JSON (for roundtrip) + Assert.NotNull(json); + var jsonObj = json!.AsObject(); + Assert.True(jsonObj.ContainsKey("invocationRequired") || jsonObj.ContainsKey("InvocationRequired")); + + JsonNode? invocationRequiredValue = null; + if (jsonObj.TryGetPropertyValue("invocationRequired", out var value1)) + { + invocationRequiredValue = value1; + } + else if (jsonObj.TryGetPropertyValue("InvocationRequired", out var value2)) + { + invocationRequiredValue = value2; + } + + Assert.NotNull(invocationRequiredValue); + Assert.Equal(invocationRequired, invocationRequiredValue!.GetValue()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InvocationRequired_Deserialization(bool invocationRequired) + { + // Test deserialization + var json = $$"""{"callId":"callId1","name":"functionName","invocationRequired":{{(invocationRequired ? "true" : "false")}}}"""; + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal("callId1", deserialized.CallId); + Assert.Equal("functionName", deserialized.Name); + Assert.Equal(invocationRequired, deserialized.InvocationRequired); + } + + [Fact] + public void InvocationRequired_DeserializedToTrueWhenMissing() + { + // Test deserialization when InvocationRequired is not in JSON (should default to true from field initializer) + var json = """{"callId":"callId1","name":"functionName"}"""; + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal("callId1", deserialized.CallId); + Assert.Equal("functionName", deserialized.Name); + Assert.True(deserialized.InvocationRequired); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void InvocationRequired_Roundtrip(bool invocationRequired) + { + // Test that InvocationRequired roundtrips correctly through JSON serialization + var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = invocationRequired }; + var json = JsonSerializer.SerializeToNode(original, TestJsonSerializerContext.Default.Options); + var deserialized = JsonSerializer.Deserialize(json, TestJsonSerializerContext.Default.Options); + + Assert.NotNull(deserialized); + Assert.Equal(original.CallId, deserialized.CallId); + Assert.Equal(original.Name, deserialized.Name); + Assert.Equal(original.InvocationRequired, deserialized.InvocationRequired); + Assert.Equal(invocationRequired, deserialized.InvocationRequired); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 01f2e111447..dd77ef370c6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1113,6 +1113,49 @@ async IAsyncEnumerable YieldInnerClientUpdates( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionCallsWithInvocationRequiredFalseAreNotReplacedWithApprovalsAsync(bool streaming) + { + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = + [ + new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")), + ] + }; + + List input = [new ChatMessage(ChatRole.User, "hello")]; + + // FunctionCallContent with InvocationRequired = false should pass through unchanged + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]), + ]; + + // Expected output should contain the same FunctionCallContent, not a FunctionApprovalRequestContent + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]), + ]; + + if (streaming) + { + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput); + } + else + { + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); + } + + // The function should NOT have been invoked since InvocationRequired was false + Assert.Equal(0, functionInvokedCount); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input, diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 066fb5e3427..6cd6e857996 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -1924,6 +1924,197 @@ public async Task CreatesOrchestrateToolsSpanWhenNoInvokeAgentParent(bool stream } } + [Fact] + public async Task InvocationRequired_SetToFalseAfterProcessing() + { + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => "Result 1", "Func1")] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + var chat = await InvokeAndAssertAsync(options, plan); + + // Find the FunctionCallContent in the chat history + var functionCallMessage = chat.First(m => m.Contents.Any(c => c is FunctionCallContent)); + var functionCallContent = functionCallMessage.Contents.OfType().First(); + + // Verify InvocationRequired was set to false after processing + Assert.False(functionCallContent.InvocationRequired); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredFalse(bool streaming) + { + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + // Create a function call that has already been processed + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + // Return a response with a FunctionCallContent that has InvocationRequired = false + var message = new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]); + return new ChatResponse(message); + }, + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + // Return a response with a FunctionCallContent that has InvocationRequired = false + var message = new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]); + return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + ChatResponse response = streaming + ? await client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options).ToChatResponseAsync() + : await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should not have been invoked since InvocationRequired was false + Assert.Equal(0, functionInvokedCount); + + // The response should contain the FunctionCallContent but no FunctionResultContent + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && !fcc.InvocationRequired)); + Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InvocationRequired_ProcessesMixedFunctionCalls(bool streaming) + { + var func1InvokedCount = 0; + var func2InvokedCount = 0; + + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => { func1InvokedCount++; return "Result 1"; }, "Func1"), + AIFunctionFactory.Create(() => { func2InvokedCount++; return "Result 2"; }, "Func2"), + ] + }; + + // Create one function call that needs processing and one that doesn't + var needsProcessing = new FunctionCallContent("callId1", "Func1") { InvocationRequired = true }; + var alreadyProcessed = new FunctionCallContent("callId2", "Func2") { InvocationRequired = false }; + + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + if (contents.Count() == 1) + { + // First call - return both function calls + var message = new ChatMessage(ChatRole.Assistant, [needsProcessing, alreadyProcessed]); + return new ChatResponse(message); + } + else + { + // Second call - return final response after processing + var message = new ChatMessage(ChatRole.Assistant, "done"); + return new ChatResponse(message); + } + }, + GetStreamingResponseAsyncCallback = (contents, actualOptions, actualCancellationToken) => + { + if (contents.Count() == 1) + { + // First call - return both function calls + var message = new ChatMessage(ChatRole.Assistant, [needsProcessing, alreadyProcessed]); + return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); + } + else + { + // Second call - return final response after processing + var message = new ChatMessage(ChatRole.Assistant, "done"); + return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); + } + } + }; + + using var client = new FunctionInvokingChatClient(innerClient); + + ChatResponse response = streaming + ? await client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, "hello")], options).ToChatResponseAsync() + : await client.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // Only Func1 should have been invoked (the one with InvocationRequired = true) + Assert.Equal(1, func1InvokedCount); + Assert.Equal(0, func2InvokedCount); + + // The response should contain FunctionResultContent for Func1 but not Func2 + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId1")); + Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId2")); + } + + [Fact] + public async Task InvocationRequired_MultipleFunctionInvokingChatClientsOnlyProcessOnce() + { + // Test that when multiple FunctionInvokingChatClients are in a pipeline, + // each FunctionCallContent is only processed once (by the first one that sees it) + var functionInvokedCount = 0; + var options = new ChatOptions + { + Tools = [AIFunctionFactory.Create(() => { functionInvokedCount++; return "Result 1"; }, "Func1")] + }; + + var callCount = 0; + using var innerClient = new TestChatClient + { + GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) => + { + await Task.Yield(); + + // First call: return a FunctionCallContent that needs processing + // Second call: return a final text response + if (callCount++ == 0) + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])); + } + else + { + return new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")); + } + } + }; + + // Create a pipeline with two FunctionInvokingChatClients + using var client1 = new FunctionInvokingChatClient(innerClient); + using var client2 = new FunctionInvokingChatClient(client1); + + var response = await client2.GetResponseAsync([new ChatMessage(ChatRole.User, "hello")], options); + + // The function should have been invoked EXACTLY ONCE, not twice (once per FICC) + Assert.Equal(1, functionInvokedCount); + + // The response should contain the FunctionCallContent with InvocationRequired = false + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.CallId == "callId1" && !fcc.InvocationRequired)); + + // There should be a FunctionResultContent since the function was processed + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "callId1")); + } + private sealed class CustomSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) @@ -1962,7 +2153,7 @@ private static async Task> InvokeAndAssertAsync( var usage = CreateRandomUsage(); expectedTotalTokenCounts += usage.InputTokenCount!.Value; - var message = new ChatMessage(ChatRole.Assistant, [.. plan[contents.Count()].Contents]) + var message = new ChatMessage(ChatRole.Assistant, CloneContents(plan[contents.Count()].Contents)) { MessageId = Guid.NewGuid().ToString("N") }; @@ -2026,7 +2217,7 @@ private static async Task> InvokeAndAssertStreamingAsync( { Assert.Equal(cts.Token, actualCancellationToken); - ChatMessage message = new(ChatRole.Assistant, [.. plan[contents.Count()].Contents]) + ChatMessage message = new(ChatRole.Assistant, CloneContents(plan[contents.Count()].Contents)) { MessageId = Guid.NewGuid().ToString("N"), }; @@ -2056,6 +2247,36 @@ private static async IAsyncEnumerable YieldAsync(params IEnumerable ite } } + /// + /// Clones AIContent objects to avoid sharing mutable state across test invocations. + /// + private static List CloneContents(IList contents) + { + var cloned = new List(contents.Count); + foreach (var content in contents) + { + // Clone FunctionCallContent to avoid sharing InvocationRequired state + if (content is FunctionCallContent fcc) + { + cloned.Add(new FunctionCallContent(fcc.CallId, fcc.Name, fcc.Arguments) + { + InvocationRequired = fcc.InvocationRequired, + Exception = fcc.Exception, + AdditionalProperties = fcc.AdditionalProperties, + Annotations = fcc.Annotations, + RawRepresentation = fcc.RawRepresentation + }); + } + else + { + // For other content types, just use the same reference + cloned.Add(content); + } + } + + return cloned; + } + [Theory] [InlineData(false)] [InlineData(true)]