Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ public FunctionCallContent(string callId, string name, IDictionary<string, objec
public Exception? Exception { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// This property defaults to <see langword="true"/>, indicating that the function call should be processed.
/// When set to <see langword="false"/>, it indicates that the function has already been processed or is otherwise
/// This property defaults to <see langword="false"/>, indicating that the function call should be processed.
/// When set to <see langword="true"/>, it indicates that the function has already been processed or is otherwise
/// purely informational and should be ignored by components that process function calls.
/// </remarks>
public bool InvocationRequired { get; set; } = true;
public bool InformationalOnly { get; set; }

/// <summary>
/// Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1125,8 +1125,8 @@ private async Task<FunctionInvocationResult> 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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1708,7 +1708,7 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent>
{
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);
Expand Down Expand Up @@ -1739,7 +1739,7 @@ private IList<ChatMessage> 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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<string, object?> { ["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<bool>());
Assert.NotNull(informationalOnlyValue);
Assert.Equal(informationalOnly, informationalOnlyValue!.GetValue<bool>());
}

[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<FunctionCallContent>(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<FunctionCallContent>(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<FunctionCallContent>(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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1116,7 +1116,7 @@ async IAsyncEnumerable<ChatResponseUpdate> 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
Expand All @@ -1130,8 +1130,8 @@ public async Task FunctionCallsWithInvocationRequiredFalseAreNotReplacedWithAppr

List<ChatMessage> 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<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]),
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<FunctionCallContent>().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
Expand All @@ -1962,21 +1962,21 @@ 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
{
GetResponseAsyncCallback = async (contents, actualOptions, actualCancellationToken) =>
{
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());
}
Expand All @@ -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;
Expand All @@ -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
{
Expand Down Expand Up @@ -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);

Expand All @@ -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)
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -2255,12 +2255,12 @@ private static List<AIContent> CloneContents(IList<AIContent> contents)
var cloned = new List<AIContent>(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,
Expand Down
Loading