diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md index 46511167121..d4d3e11d767 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CHANGELOG.md @@ -3,7 +3,7 @@ ## NOT YET RELEASED - Unsealed `FunctionCallContent` and `FunctionResultContent`. -- Added `InvocationRequired` property to `FunctionCallContent` to indicate whether function invocation is required. +- Added `InformationalOnly` property to `FunctionCallContent` to indicate whether the content is informing the consumer about a call that's being made elsewhere or that is a request for the call to be performed. - Added `LoadFromAsync` and `SaveToAsync` helper methods to `DataContent` for file I/O operations. - Fixed JSON schema generation for nullable reference type annotations on parameters in AIFunctions. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index b8a135b7e1d..6ec7febc486 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -57,14 +57,14 @@ public FunctionCallContent(string callId, string name, IDictionary - /// Gets or sets a value indicating whether this function call requires invocation. + /// Gets or sets a value indicating whether this function call is purely informational. /// /// - /// 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 + /// 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; + public bool InformationalOnly { get; set; } /// /// 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 e6ad6cc28b6..2f2af02a254 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 @@ -1818,7 +1818,7 @@ "Stage": "Stable" }, { - "Member": "bool Microsoft.Extensions.AI.FunctionCallContent.InvocationRequired { get; set; }", + "Member": "bool Microsoft.Extensions.AI.FunctionCallContent.InformationalOnly { get; set; }", "Stage": "Stable" }, { diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index cd449248867..260b2ab79a1 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 && functionCall.InvocationRequired) + if (content[i] is FunctionCallContent functionCall && !functionCall.InformationalOnly) { (functionCalls ??= []).Add(functionCall); any = true; @@ -1125,8 +1125,8 @@ 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; + // Mark the function call as purely informational since we're handling it + callContent.InformationalOnly = true; // 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); @@ -1568,8 +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; + // Mark the function call as purely informational since we're handling it (by rejecting it) + m.Response.FunctionCall.InformationalOnly = true; return (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, result); }) : null; @@ -1708,7 +1708,7 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList { for (int i = 0; i < content.Count; i++) { - if (content[i] is FunctionCallContent fcc && fcc.InvocationRequired) + if (content[i] is FunctionCallContent fcc && !fcc.InformationalOnly) { updatedContent ??= [.. content]; // Clone the list if we haven't already updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); @@ -1739,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 && functionCall.InvocationRequired) + if (content[j] is FunctionCallContent functionCall && !functionCall.InformationalOnly) { (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 394728b7236..9fde659d65f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs @@ -28,7 +28,7 @@ public void Constructor_PropsDefault() Assert.Null(c.Arguments); Assert.Null(c.Exception); - Assert.True(c.InvocationRequired); + Assert.False(c.InformationalOnly); } [Fact] @@ -73,87 +73,87 @@ public void Constructor_PropsRoundtrip() c.Exception = e; Assert.Same(e, c.Exception); - Assert.True(c.InvocationRequired); - c.InvocationRequired = false; - Assert.False(c.InvocationRequired); + Assert.False(c.InformationalOnly); + c.InformationalOnly = true; + Assert.True(c.InformationalOnly); } [Theory] [InlineData(true)] [InlineData(false)] - public void InvocationRequired_Serialization(bool invocationRequired) + public void InformationalOnly_Serialization(bool informationalOnly) { // Arrange var sut = new FunctionCallContent("callId1", "functionName", new Dictionary { ["key"] = "value" }) { - InvocationRequired = invocationRequired + InformationalOnly = informationalOnly }; // Act var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options); - // Assert - InvocationRequired should always be in the JSON (for roundtrip) + // Assert - InformationalOnly should always be in the JSON (for roundtrip) Assert.NotNull(json); var jsonObj = json!.AsObject(); - Assert.True(jsonObj.ContainsKey("invocationRequired") || jsonObj.ContainsKey("InvocationRequired")); + Assert.True(jsonObj.ContainsKey("informationalOnly") || jsonObj.ContainsKey("InformationalOnly")); - JsonNode? invocationRequiredValue = null; - if (jsonObj.TryGetPropertyValue("invocationRequired", out var value1)) + JsonNode? informationalOnlyValue = null; + if (jsonObj.TryGetPropertyValue("informationalOnly", out var value1)) { - invocationRequiredValue = value1; + informationalOnlyValue = value1; } - else if (jsonObj.TryGetPropertyValue("InvocationRequired", out var value2)) + else if (jsonObj.TryGetPropertyValue("InformationalOnly", out var value2)) { - invocationRequiredValue = value2; + informationalOnlyValue = value2; } - Assert.NotNull(invocationRequiredValue); - Assert.Equal(invocationRequired, invocationRequiredValue!.GetValue()); + Assert.NotNull(informationalOnlyValue); + Assert.Equal(informationalOnly, informationalOnlyValue!.GetValue()); } [Theory] [InlineData(true)] [InlineData(false)] - public void InvocationRequired_Deserialization(bool invocationRequired) + public void InformationalOnly_Deserialization(bool informationalOnly) { // Test deserialization - var json = $$"""{"callId":"callId1","name":"functionName","invocationRequired":{{(invocationRequired ? "true" : "false")}}}"""; + var json = $$"""{"callId":"callId1","name":"functionName","informationalOnly":{{(informationalOnly ? "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); + Assert.Equal(informationalOnly, deserialized.InformationalOnly); } [Fact] - public void InvocationRequired_DeserializedToTrueWhenMissing() + public void InformationalOnly_DeserializedToFalseWhenMissing() { - // Test deserialization when InvocationRequired is not in JSON (should default to true from field initializer) + // Test deserialization when InformationalOnly is not in JSON (should default to false 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); + Assert.False(deserialized.InformationalOnly); } [Theory] [InlineData(true)] [InlineData(false)] - public void InvocationRequired_Roundtrip(bool invocationRequired) + public void InformationalOnly_Roundtrip(bool informationalOnly) { - // Test that InvocationRequired roundtrips correctly through JSON serialization - var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = invocationRequired }; + // Test that InformationalOnly roundtrips correctly through JSON serialization + var original = new FunctionCallContent("callId1", "functionName") { InformationalOnly = informationalOnly }; 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); + Assert.Equal(original.InformationalOnly, deserialized.InformationalOnly); + Assert.Equal(informationalOnly, deserialized.InformationalOnly); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index dd77ef370c6..0feac8ac3c4 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1116,7 +1116,7 @@ async IAsyncEnumerable YieldInnerClientUpdates( [Theory] [InlineData(false)] [InlineData(true)] - public async Task FunctionCallsWithInvocationRequiredFalseAreNotReplacedWithApprovalsAsync(bool streaming) + public async Task FunctionCallsWithInformationalOnlyTrueAreNotReplacedWithApprovalsAsync(bool streaming) { var functionInvokedCount = 0; var options = new ChatOptions @@ -1130,8 +1130,8 @@ public async Task FunctionCallsWithInvocationRequiredFalseAreNotReplacedWithAppr List input = [new ChatMessage(ChatRole.User, "hello")]; - // FunctionCallContent with InvocationRequired = false should pass through unchanged - var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + // FunctionCallContent with InformationalOnly = true should pass through unchanged + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InformationalOnly = true }; List downstreamClientOutput = [ new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]), @@ -1152,7 +1152,7 @@ public async Task FunctionCallsWithInvocationRequiredFalseAreNotReplacedWithAppr await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput); } - // The function should NOT have been invoked since InvocationRequired was false + // The function should NOT have been invoked since InformationalOnly was true Assert.Equal(0, functionInvokedCount); } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 6cd6e857996..2ddf757d185 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -1925,7 +1925,7 @@ public async Task CreatesOrchestrateToolsSpanWhenNoInvokeAgentParent(bool stream } [Fact] - public async Task InvocationRequired_SetToFalseAfterProcessing() + public async Task InformationalOnly_SetToTrueAfterProcessing() { var options = new ChatOptions { @@ -1946,14 +1946,14 @@ public async Task InvocationRequired_SetToFalseAfterProcessing() 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); + // Verify InformationalOnly was set to true after processing + Assert.True(functionCallContent.InformationalOnly); } [Theory] [InlineData(false)] [InlineData(true)] - public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredFalse(bool streaming) + public async Task InformationalOnly_IgnoresFunctionCallsWithInformationalOnlyTrue(bool streaming) { var functionInvokedCount = 0; var options = new ChatOptions @@ -1962,7 +1962,7 @@ public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredF }; // Create a function call that has already been processed - var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false }; + var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InformationalOnly = true }; using var innerClient = new TestChatClient { @@ -1970,13 +1970,13 @@ public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredF { await Task.Yield(); - // Return a response with a FunctionCallContent that has InvocationRequired = false + // Return a response with a FunctionCallContent that has InformationalOnly = true 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 + // Return a response with a FunctionCallContent that has InformationalOnly = true var message = new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]); return YieldAsync(new ChatResponse(message).ToChatResponseUpdates()); } @@ -1988,18 +1988,18 @@ public async Task InvocationRequired_IgnoresFunctionCallsWithInvocationRequiredF ? 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 + // The function should not have been invoked since InformationalOnly was true 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.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.InformationalOnly)); Assert.DoesNotContain(response.Messages, m => m.Contents.Any(c => c is FunctionResultContent)); } [Theory] [InlineData(false)] [InlineData(true)] - public async Task InvocationRequired_ProcessesMixedFunctionCalls(bool streaming) + public async Task InformationalOnly_ProcessesMixedFunctionCalls(bool streaming) { var func1InvokedCount = 0; var func2InvokedCount = 0; @@ -2014,8 +2014,8 @@ public async Task InvocationRequired_ProcessesMixedFunctionCalls(bool streaming) }; // 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 }; + var needsProcessing = new FunctionCallContent("callId1", "Func1") { InformationalOnly = false }; + var alreadyProcessed = new FunctionCallContent("callId2", "Func2") { InformationalOnly = true }; using var innerClient = new TestChatClient { @@ -2059,7 +2059,7 @@ public async Task InvocationRequired_ProcessesMixedFunctionCalls(bool 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) + // Only Func1 should have been invoked (the one with InformationalOnly = false) Assert.Equal(1, func1InvokedCount); Assert.Equal(0, func2InvokedCount); @@ -2069,7 +2069,7 @@ public async Task InvocationRequired_ProcessesMixedFunctionCalls(bool streaming) } [Fact] - public async Task InvocationRequired_MultipleFunctionInvokingChatClientsOnlyProcessOnce() + public async Task InformationalOnly_MultipleFunctionInvokingChatClientsOnlyProcessOnce() { // Test that when multiple FunctionInvokingChatClients are in a pipeline, // each FunctionCallContent is only processed once (by the first one that sees it) @@ -2108,8 +2108,8 @@ public async Task InvocationRequired_MultipleFunctionInvokingChatClientsOnlyProc // 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)); + // The response should contain the FunctionCallContent with InformationalOnly = true + Assert.Contains(response.Messages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.CallId == "callId1" && fcc.InformationalOnly)); // 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")); @@ -2255,12 +2255,12 @@ private static List CloneContents(IList contents) var cloned = new List(contents.Count); foreach (var content in contents) { - // Clone FunctionCallContent to avoid sharing InvocationRequired state + // Clone FunctionCallContent to avoid sharing InformationalOnly state if (content is FunctionCallContent fcc) { cloned.Add(new FunctionCallContent(fcc.CallId, fcc.Name, fcc.Arguments) { - InvocationRequired = fcc.InvocationRequired, + InformationalOnly = fcc.InformationalOnly, Exception = fcc.Exception, AdditionalProperties = fcc.AdditionalProperties, Annotations = fcc.Annotations,