Skip to content

Commit d06d230

Browse files
.Net: Add a request index to the streamed function call update content (#10129)
### Motivation and Context: Today, it's impossible to determine which request a streaming function call update belongs to or originates from. As a result, if an AI service interaction leads to multiple requests to an AI model - let's say one per function call - it's impossible to assemble the function call contents from the streamed updates. This is because the streaming function call updates from the second request override those from the first one. ### Description This PR adds the `RequestIndex` to the `StreamingFunctionCallUpdateContent` class and updates the {Azure}OpenAI connectors to set it. Finally, it updates the `FunctionCallContentBuilder` class to construct a composite index from `RequestIndex` and `FunctionCallIndex` and uses it to uniquely identify the function call content updates across requests. Closes: #10006
1 parent 22cfbd5 commit d06d230

File tree

5 files changed

+91
-25
lines changed

5 files changed

+91
-25
lines changed

dotnet/samples/Concepts/FunctionCalling/FunctionCalling.cs

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public async Task RunPromptWithAutoFunctionChoiceBehaviorAdvertisingAllKernelFun
7272

7373
OpenAIPromptExecutionSettings settings = new() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto() };
7474

75-
Console.WriteLine(await kernel.InvokePromptAsync("Given the current time of day and weather, what is the likely color of the sky in Boston?", new(settings)));
75+
Console.WriteLine(await kernel.InvokePromptAsync("What is the likely color of the sky in Boston today?", new(settings)));
7676

7777
// Expected output: "Boston is currently experiencing a rainy day, hence, the likely color of the sky in Boston is grey."
7878
}
@@ -104,7 +104,7 @@ public async Task RunPromptWithNoneFunctionChoiceBehaviorAdvertisingAllKernelFun
104104

105105
OpenAIPromptExecutionSettings settings = new() { FunctionChoiceBehavior = FunctionChoiceBehavior.None() };
106106

107-
Console.WriteLine(await kernel.InvokePromptAsync("Tell me which provided functions I would need to call to get the color of the sky in Boston on a specified date.", new(settings)));
107+
Console.WriteLine(await kernel.InvokePromptAsync("Tell me which provided functions I would need to call to get the color of the sky in Boston for today.", new(settings)));
108108

109109
// Expected output: "You would first call the `HelperFunctions-GetCurrentUtcDateTime` function to get the current date time in UTC. Then, you would use the `HelperFunctions-GetWeatherForCity` function,
110110
// passing in the city name as 'Boston' and the retrieved UTC date time. Note, however, that these functions won't directly tell you the color of the sky.
@@ -122,7 +122,7 @@ public async Task RunPromptTemplateConfigWithAutoFunctionChoiceBehaviorAdvertisi
122122
// The `function_choice_behavior.functions` property is omitted which is equivalent to providing all kernel functions to the AI model.
123123
string promptTemplateConfig = """
124124
template_format: semantic-kernel
125-
template: Given the current time of day and weather, what is the likely color of the sky in Boston?
125+
template: What is the likely color of the sky in Boston today?
126126
execution_settings:
127127
default:
128128
function_choice_behavior:
@@ -177,7 +177,7 @@ public async Task RunNonStreamingChatCompletionApiWithAutomaticFunctionInvocatio
177177
IChatCompletionService chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
178178

179179
ChatMessageContent result = await chatCompletionService.GetChatMessageContentAsync(
180-
"Given the current time of day and weather, what is the likely color of the sky in Boston?",
180+
"What is the likely color of the sky in Boston today?",
181181
settings,
182182
kernel);
183183

@@ -204,7 +204,7 @@ public async Task RunStreamingChatCompletionApiWithAutomaticFunctionInvocationAs
204204

205205
// Act
206206
await foreach (var update in chatCompletionService.GetStreamingChatMessageContentsAsync(
207-
"Given the current time of day and weather, what is the likely color of the sky in Boston?",
207+
"What is the likely color of the sky in Boston today?",
208208
settings,
209209
kernel))
210210
{
@@ -231,7 +231,7 @@ public async Task RunNonStreamingChatCompletionApiWithManualFunctionInvocationAs
231231
OpenAIPromptExecutionSettings settings = new() { FunctionChoiceBehavior = Microsoft.SemanticKernel.FunctionChoiceBehavior.Auto(autoInvoke: false) };
232232

233233
ChatHistory chatHistory = [];
234-
chatHistory.AddUserMessage("Given the current time of day and weather, what is the likely color of the sky in Boston?");
234+
chatHistory.AddUserMessage("What is the likely color of the sky in Boston today?");
235235

236236
while (true)
237237
{
@@ -293,7 +293,7 @@ public async Task RunStreamingChatCompletionApiWithManualFunctionCallingAsync()
293293

294294
// Create chat history with the initial user message
295295
ChatHistory chatHistory = [];
296-
chatHistory.AddUserMessage("Given the current time of day and weather, what is the likely color of the sky in Boston?");
296+
chatHistory.AddUserMessage("What is the likely color of the sky in Boston today?");
297297

298298
while (true)
299299
{
@@ -360,7 +360,7 @@ public async Task RunNonStreamingPromptWithSimulatedFunctionAsync()
360360
OpenAIPromptExecutionSettings settings = new() { FunctionChoiceBehavior = Microsoft.SemanticKernel.FunctionChoiceBehavior.Auto(autoInvoke: false) };
361361

362362
ChatHistory chatHistory = [];
363-
chatHistory.AddUserMessage("Given the current time of day and weather, what is the likely color of the sky in Boston?");
363+
chatHistory.AddUserMessage("What is the likely color of the sky in Boston today?");
364364

365365
while (true)
366366
{
@@ -411,7 +411,7 @@ public async Task DisableFunctionCallingAsync()
411411
// Alternatively, either omit assigning anything to the `FunctionChoiceBehavior` property or assign null to it to also disable function calling.
412412
OpenAIPromptExecutionSettings settings = new() { FunctionChoiceBehavior = FunctionChoiceBehavior.Auto(functions: []) };
413413

414-
Console.WriteLine(await kernel.InvokePromptAsync("Given the current time of day and weather, what is the likely color of the sky in Boston?", new(settings)));
414+
Console.WriteLine(await kernel.InvokePromptAsync("What is the likely color of the sky in Boston today?", new(settings)));
415415

416416
// Expected output: "Sorry, I cannot answer this question as it requires real-time information which I, as a text-based model, cannot access."
417417
}
@@ -538,8 +538,8 @@ private static Kernel CreateKernel()
538538
kernel.ImportPluginFromFunctions("HelperFunctions",
539539
[
540540
kernel.CreateFunctionFromMethod(() => new List<string> { "Squirrel Steals Show", "Dog Wins Lottery" }, "GetLatestNewsTitles", "Retrieves latest news titles."),
541-
kernel.CreateFunctionFromMethod(() => DateTime.UtcNow.ToString("R"), "GetCurrentUtcDateTime", "Retrieves the current date time in UTC."),
542-
kernel.CreateFunctionFromMethod((string cityName, string currentDateTime) =>
541+
kernel.CreateFunctionFromMethod(() => DateTime.UtcNow.ToString("R"), "GetCurrentDateTimeInUtc", "Retrieves the current date time in UTC."),
542+
kernel.CreateFunctionFromMethod((string cityName, string currentDateTimeInUtc) =>
543543
cityName switch
544544
{
545545
"Boston" => "61 and rainy",
@@ -550,7 +550,7 @@ private static Kernel CreateKernel()
550550
"Sydney" => "75 and sunny",
551551
"Tel Aviv" => "80 and sunny",
552552
_ => "31 and snowing",
553-
}, "GetWeatherForCity", "Gets the current weather for the specified city"),
553+
}, "GetWeatherForCity", "Gets the current weather for the specified city and specified date time."),
554554
]);
555555

556556
return kernel;

dotnet/src/Connectors/Connectors.OpenAI/Core/ClientCore.ChatCompletion.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,10 @@ internal async IAsyncEnumerable<OpenAIStreamingChatMessageContent> GetStreamingC
332332
callId: functionCallUpdate.ToolCallId,
333333
name: functionCallUpdate.FunctionName,
334334
arguments: streamingArguments,
335-
functionCallIndex: functionCallUpdate.Index));
335+
functionCallIndex: functionCallUpdate.Index)
336+
{
337+
RequestIndex = requestIndex,
338+
});
336339
}
337340
}
338341
streamedContents?.Add(openAIStreamingChatMessageContent);

dotnet/src/SemanticKernel.Abstractions/Contents/FunctionCallContentBuilder.cs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ namespace Microsoft.SemanticKernel;
1515
/// </summary>
1616
public sealed class FunctionCallContentBuilder
1717
{
18-
private Dictionary<int, string>? _functionCallIdsByIndex = null;
19-
private Dictionary<int, string>? _functionNamesByIndex = null;
20-
private Dictionary<int, StringBuilder>? _functionArgumentBuildersByIndex = null;
18+
private Dictionary<string, string>? _functionCallIdsByIndex = null;
19+
private Dictionary<string, string>? _functionNamesByIndex = null;
20+
private Dictionary<string, StringBuilder>? _functionArgumentBuildersByIndex = null;
2121
private readonly JsonSerializerOptions? _jsonSerializerOptions;
2222

2323
/// <summary>
@@ -70,7 +70,7 @@ public IReadOnlyList<FunctionCallContent> Build()
7070

7171
for (int i = 0; i < this._functionCallIdsByIndex.Count; i++)
7272
{
73-
KeyValuePair<int, string> functionCallIndexAndId = this._functionCallIdsByIndex.ElementAt(i);
73+
KeyValuePair<string, string> functionCallIndexAndId = this._functionCallIdsByIndex.ElementAt(i);
7474

7575
string? pluginName = null;
7676
string functionName = string.Empty;
@@ -96,7 +96,7 @@ public IReadOnlyList<FunctionCallContent> Build()
9696

9797
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "The warning is shown and should be addressed at the class creation site; there is no need to show it again at the function invocation sites.")]
9898
[UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "The warning is shown and should be addressed at the class creation site; there is no need to show it again at the function invocation sites.")]
99-
(KernelArguments? Arguments, Exception? Exception) GetFunctionArgumentsSafe(int functionCallIndex)
99+
(KernelArguments? Arguments, Exception? Exception) GetFunctionArgumentsSafe(string functionCallIndex)
100100
{
101101
if (this._jsonSerializerOptions is not null)
102102
{
@@ -118,7 +118,7 @@ public IReadOnlyList<FunctionCallContent> Build()
118118
/// <returns>A tuple containing the KernelArguments and an Exception if any.</returns>
119119
[RequiresUnreferencedCode("Uses reflection to deserialize function arguments if no JSOs are provided, making it incompatible with AOT scenarios.")]
120120
[RequiresDynamicCode("Uses reflection to deserialize function arguments if no JSOs are provided, making it incompatible with AOT scenarios.")]
121-
private (KernelArguments? Arguments, Exception? Exception) GetFunctionArguments(int functionCallIndex, JsonSerializerOptions? jsonSerializerOptions = null)
121+
private (KernelArguments? Arguments, Exception? Exception) GetFunctionArguments(string functionCallIndex, JsonSerializerOptions? jsonSerializerOptions = null)
122122
{
123123
if (this._functionArgumentBuildersByIndex is null ||
124124
!this._functionArgumentBuildersByIndex.TryGetValue(functionCallIndex, out StringBuilder? functionArgumentsBuilder))
@@ -170,33 +170,36 @@ public IReadOnlyList<FunctionCallContent> Build()
170170
/// <param name="functionCallIdsByIndex">The dictionary of function call IDs by function call index.</param>
171171
/// <param name="functionNamesByIndex">The dictionary of function names by function call index.</param>
172172
/// <param name="functionArgumentBuildersByIndex">The dictionary of function argument builders by function call index.</param>
173-
private static void TrackStreamingFunctionCallUpdate(StreamingFunctionCallUpdateContent update, ref Dictionary<int, string>? functionCallIdsByIndex, ref Dictionary<int, string>? functionNamesByIndex, ref Dictionary<int, StringBuilder>? functionArgumentBuildersByIndex)
173+
private static void TrackStreamingFunctionCallUpdate(StreamingFunctionCallUpdateContent update, ref Dictionary<string, string>? functionCallIdsByIndex, ref Dictionary<string, string>? functionNamesByIndex, ref Dictionary<string, StringBuilder>? functionArgumentBuildersByIndex)
174174
{
175175
if (update is null)
176176
{
177177
// Nothing to track.
178178
return;
179179
}
180180

181+
// Create index that is unique across many requests.
182+
var functionCallIndex = $"{update.RequestIndex}-{update.FunctionCallIndex}";
183+
181184
// If we have an call id, ensure the index is being tracked. Even if it's not a function update,
182185
// we want to keep track of it so we can send back an error.
183186
if (update.CallId is string id && !string.IsNullOrEmpty(id))
184187
{
185-
(functionCallIdsByIndex ??= [])[update.FunctionCallIndex] = id;
188+
(functionCallIdsByIndex ??= [])[functionCallIndex] = id;
186189
}
187190

188191
// Ensure we're tracking the function's name.
189192
if (update.Name is string name && !string.IsNullOrEmpty(name))
190193
{
191-
(functionNamesByIndex ??= [])[update.FunctionCallIndex] = name;
194+
(functionNamesByIndex ??= [])[functionCallIndex] = name;
192195
}
193196

194197
// Ensure we're tracking the function's arguments.
195198
if (update.Arguments is string argumentsUpdate)
196199
{
197-
if (!(functionArgumentBuildersByIndex ??= []).TryGetValue(update.FunctionCallIndex, out StringBuilder? arguments))
200+
if (!(functionArgumentBuildersByIndex ??= []).TryGetValue(functionCallIndex, out StringBuilder? arguments))
198201
{
199-
functionArgumentBuildersByIndex[update.FunctionCallIndex] = arguments = new();
202+
functionArgumentBuildersByIndex[functionCallIndex] = arguments = new();
200203
}
201204

202205
arguments.Append(argumentsUpdate);

dotnet/src/SemanticKernel.Abstractions/Contents/StreamingFunctionCallUpdateContent.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Microsoft. All rights reserved.
22

3+
using System.Diagnostics.CodeAnalysis;
34
using System.Text;
45

56
namespace Microsoft.SemanticKernel;
@@ -29,6 +30,12 @@ public class StreamingFunctionCallUpdateContent : StreamingKernelContent
2930
/// </summary>
3031
public int FunctionCallIndex { get; init; }
3132

33+
/// <summary>
34+
/// Index of the request that produced this message content.
35+
/// </summary>
36+
[Experimental("SKEXP0001")]
37+
public int RequestIndex { get; init; } = 0;
38+
3239
/// <summary>
3340
/// Creates a new instance of the <see cref="StreamingFunctionCallUpdateContent"/> class.
3441
/// </summary>

dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallBuilder/FunctionCallContentBuilderTests.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,58 @@ public void ItShouldBuildFunctionCallContentForManyFunctions(JsonSerializerOptio
130130
Assert.Null(functionCall2.Exception);
131131
}
132132

133+
[Theory]
134+
[ClassData(typeof(TestJsonSerializerOptionsForKernelArguments))]
135+
public void ItShouldBuildFunctionCallContentForManyFunctionsCameInDifferentRequests(JsonSerializerOptions? jsos)
136+
{
137+
// Arrange
138+
var sut = jsos is not null ? new FunctionCallContentBuilder(jsos) : new FunctionCallContentBuilder();
139+
140+
// Act
141+
142+
// f1 call was streamed as part of the first request
143+
var f1_update1 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 0, callId: "f_1", name: "WeatherUtils-GetTemperature", arguments: null);
144+
sut.Append(f1_update1);
145+
146+
var f1_update2 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 0, callId: null, name: null, arguments: "{\"city\":");
147+
sut.Append(f1_update2);
148+
149+
var f1_update3 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 0, callId: null, name: null, arguments: "\"Seattle\"}");
150+
sut.Append(f1_update3);
151+
152+
// f2 call was streamed as part of the second request
153+
var f2_update1 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 1, callId: null, name: "WeatherUtils-GetHumidity", arguments: null);
154+
sut.Append(f2_update1);
155+
156+
var f2_update2 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 1, callId: "f_2", name: null, arguments: null);
157+
sut.Append(f2_update2);
158+
159+
var f2_update3 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 1, callId: null, name: null, arguments: "{\"city\":");
160+
sut.Append(f2_update3);
161+
162+
var f2_update4 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 0, functionCallIndex: 0, requestIndex: 1, callId: null, name: null, arguments: "\"Georgia\"}");
163+
sut.Append(f2_update4);
164+
165+
var functionCalls = sut.Build();
166+
167+
// Assert
168+
Assert.Equal(2, functionCalls.Count);
169+
170+
var functionCall1 = functionCalls.ElementAt(0);
171+
Assert.Equal("f_1", functionCall1.Id);
172+
Assert.Equal("WeatherUtils", functionCall1.PluginName);
173+
Assert.Equal("GetTemperature", functionCall1.FunctionName);
174+
Assert.Equal("Seattle", functionCall1.Arguments?["city"]);
175+
Assert.Null(functionCall1.Exception);
176+
177+
var functionCall2 = functionCalls.ElementAt(1);
178+
Assert.Equal("f_2", functionCall2.Id);
179+
Assert.Equal("WeatherUtils", functionCall2.PluginName);
180+
Assert.Equal("GetHumidity", functionCall2.FunctionName);
181+
Assert.Equal("Georgia", functionCall2.Arguments?["city"]);
182+
Assert.Null(functionCall2.Exception);
183+
}
184+
133185
[Theory]
134186
[ClassData(typeof(TestJsonSerializerOptionsForKernelArguments))]
135187
public void ItShouldCaptureArgumentsDeserializationException(JsonSerializerOptions? jsos)
@@ -160,7 +212,7 @@ public void ItShouldCaptureArgumentsDeserializationException(JsonSerializerOptio
160212
Assert.NotNull(functionCall.Exception);
161213
}
162214

163-
private static StreamingChatMessageContent CreateStreamingContentWithFunctionCallUpdate(int choiceIndex, int functionCallIndex, string? callId, string? name, string? arguments)
215+
private static StreamingChatMessageContent CreateStreamingContentWithFunctionCallUpdate(int choiceIndex, int functionCallIndex, string? callId, string? name, string? arguments, int requestIndex = 0)
164216
{
165217
var content = new StreamingChatMessageContent(AuthorRole.Assistant, null);
166218

@@ -171,6 +223,7 @@ private static StreamingChatMessageContent CreateStreamingContentWithFunctionCal
171223
CallId = callId,
172224
Name = name,
173225
Arguments = arguments,
226+
RequestIndex = requestIndex
174227
});
175228

176229
return content;

0 commit comments

Comments
 (0)