Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ public FunctionCallContent(string callId, string name, IDictionary<string, objec
[JsonIgnore]
public Exception? Exception { get; set; }

/// <summary>
/// Gets or sets a value indicating whether this function call requires invocation.
/// </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
/// purely informational and should be ignored by components that process function calls.
/// </remarks>
public bool InvocationRequired { get; set; } = true;

/// <summary>
/// Creates a new instance of <see cref="FunctionCallContent"/> parsing arguments using a specified encoding and parser.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
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)
if (content[i] is FunctionCallContent functionCall && functionCall.InvocationRequired)
{
(functionCalls ??= []).Add(functionCall);
any = true;
Expand Down Expand Up @@ -1125,6 +1125,9 @@ 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;

// 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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1703,7 +1708,7 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList<AIContent>
{
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);
Expand Down Expand Up @@ -1734,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)
if (content[j] is FunctionCallContent functionCall && functionCall.InvocationRequired)
{
(allFunctionCallContentIndices ??= []).Add((i, j));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public void Constructor_PropsDefault()

Assert.Null(c.Arguments);
Assert.Null(c.Exception);
Assert.True(c.InvocationRequired);
}

[Fact]
Expand Down Expand Up @@ -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<string, object?> { ["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<bool>());
}

[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<FunctionCallContent>(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<FunctionCallContent>(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<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);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,49 @@ async IAsyncEnumerable<ChatResponseUpdate> 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<ChatMessage> input = [new ChatMessage(ChatRole.User, "hello")];

// FunctionCallContent with InvocationRequired = false should pass through unchanged
var alreadyProcessedFunctionCall = new FunctionCallContent("callId1", "Func1") { InvocationRequired = false };
List<ChatMessage> downstreamClientOutput =
[
new ChatMessage(ChatRole.Assistant, [alreadyProcessedFunctionCall]),
];

// Expected output should contain the same FunctionCallContent, not a FunctionApprovalRequestContent
List<ChatMessage> 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<List<ChatMessage>> InvokeAndAssertAsync(
ChatOptions? options,
List<ChatMessage> input,
Expand Down
Loading
Loading