Skip to content
Open
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 @@ -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 @@ -1426,14 +1432,16 @@ private static (List<ApprovalResultWithRequestMessage>? approvals, List<Approval
/// <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(m =>
rejections.ConvertAll(static m =>
{
string result = "Tool call invocation rejected.";
if (!string.IsNullOrWhiteSpace(m.Response.Reason))
{
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
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