Skip to content
Closed
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 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 @@ -1805,6 +1805,10 @@
"Member": "System.Exception? Microsoft.Extensions.AI.FunctionCallContent.Exception { get; set; }",
"Stage": "Stable"
},
{
"Member": "bool Microsoft.Extensions.AI.FunctionCallContent.InvocationRequired { get; set; }",
"Stage": "Experimental"
},
{
"Member": "string Microsoft.Extensions.AI.FunctionCallContent.Name { get; }",
"Stage": "Stable"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,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 @@ -1018,6 +1018,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.
if (toolMap is null ||
!toolMap.TryGetValue(callContent.Name, out AITool? tool) ||
Expand Down Expand Up @@ -1107,6 +1110,9 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul
functionResult = message;
}

// Mark the function call as having been processed
result.CallContent.InvocationRequired = false;

return new FunctionResultContent(result.CallContent.CallId, functionResult) { Exception = result.Exception };
}
}
Expand Down Expand Up @@ -1416,10 +1422,20 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
/// </summary>
/// <param name="rejections">Any rejected approval responses.</param>
/// <returns>The <see cref="AIContent"/> for the rejected function calls.</returns>
private static List<AIContent>? GenerateRejectedFunctionResults(List<ApprovalResultWithRequestMessage>? rejections) =>
rejections is { Count: > 0 } ?
rejections.ConvertAll(static m => (AIContent)new FunctionResultContent(m.Response.FunctionCall.CallId, "Error: Tool call invocation was rejected by user.")) :
null;
private static List<AIContent>? GenerateRejectedFunctionResults(List<ApprovalResultWithRequestMessage>? rejections)
{
if (rejections is not { Count: > 0 })
{
return null;
}

return rejections.ConvertAll(static m =>
{
// 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, "Error: Tool call invocation was rejected by user.");
});
}

/// <summary>
/// Extracts the <see cref="FunctionCallContent"/> from the provided <see cref="FunctionApprovalResponseContent"/> to recreate the original function call messages.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,152 @@ 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);
}

[Fact]
public void InvocationRequired_DefaultsToTrue()
{
FunctionCallContent c = new("callId1", "name");
Assert.True(c.InvocationRequired);
}

[Fact]
public void InvocationRequired_CanBeSetToFalse()
{
FunctionCallContent c = new("callId1", "name") { InvocationRequired = false };
Assert.False(c.InvocationRequired);
}

[Fact]
public void InvocationRequired_SerializedWhenFalse()
{
// Arrange - Set InvocationRequired to false (to allow roundtrip, it must be serialized even when false)
var sut = new FunctionCallContent("callId1", "functionName", new Dictionary<string, object?> { ["key"] = "value" })
{
InvocationRequired = false
};

// Act
var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options);

// Assert - InvocationRequired should be in the JSON when it's false to allow 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.False(invocationRequiredValue!.GetValue<bool>());
}

[Fact]
public void InvocationRequired_SerializedWhenTrue()
{
// Arrange - InvocationRequired defaults to true
var sut = new FunctionCallContent("callId1", "functionName", new Dictionary<string, object?> { ["key"] = "value" });

// Act
var json = JsonSerializer.SerializeToNode(sut, TestJsonSerializerContext.Default.Options);

// Assert - InvocationRequired should be in the JSON when it's true
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.True(invocationRequiredValue!.GetValue<bool>());
}

[Fact]
public void InvocationRequired_DeserializedCorrectlyWhenTrue()
{
// Test deserialization when InvocationRequired is true
var json = """{"callId":"callId1","name":"functionName","invocationRequired":true}""";
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);
}

[Fact]
public void InvocationRequired_DeserializedCorrectlyWhenFalse()
{
// Test deserialization when InvocationRequired is false
var json = """{"callId":"callId1","name":"functionName","invocationRequired":false}""";
var deserialized = JsonSerializer.Deserialize<FunctionCallContent>(json, TestJsonSerializerContext.Default.Options);

Assert.NotNull(deserialized);
Assert.Equal("callId1", deserialized.CallId);
Assert.Equal("functionName", deserialized.Name);
Assert.False(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);
}

[Fact]
public void InvocationRequired_RoundtripTrue()
{
// Test that InvocationRequired=true roundtrips correctly through JSON serialization
var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = true };
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.True(deserialized.InvocationRequired);
}

[Fact]
public void InvocationRequired_RoundtripFalse()
{
// Test that InvocationRequired=false roundtrips correctly through JSON serialization
var original = new FunctionCallContent("callId1", "functionName") { InvocationRequired = false };
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.False(deserialized.InvocationRequired);
}

[Fact]
Expand Down
Loading
Loading