diff --git a/dotnet/src/SemanticKernel.Abstractions/Contents/FunctionCallContentBuilder.cs b/dotnet/src/SemanticKernel.Abstractions/Contents/FunctionCallContentBuilder.cs index ff4f6af246e5..89c3f5bff963 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Contents/FunctionCallContentBuilder.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Contents/FunctionCallContentBuilder.cs @@ -77,7 +77,7 @@ public IReadOnlyList Build() if (this._functionNamesByIndex?.TryGetValue(functionCallIndexAndId.Key, out string? fqn) ?? false) { - var functionFullyQualifiedName = Microsoft.SemanticKernel.FunctionName.Parse(fqn); + var functionFullyQualifiedName = ParseFullyQualifiedFunctionName(fqn); pluginName = functionFullyQualifiedName.PluginName; functionName = functionFullyQualifiedName.Name; } @@ -170,6 +170,28 @@ public IReadOnlyList Build() /// The dictionary of function call IDs by function call index. /// The dictionary of function names by function call index. /// The dictionary of function argument builders by function call index. + + /// + /// Parses a fully-qualified function name into plugin and function parts. + /// Tries multiple separators (".", "_", "-") since different AI connectors use different formats. + /// + private static FunctionName ParseFullyQualifiedFunctionName(string fullyQualifiedName) + { + foreach (var separator in new[] { ".", "_", "-" }) + { + if (fullyQualifiedName.Contains(separator, StringComparison.Ordinal)) + { + var parsed = FunctionName.Parse(fullyQualifiedName, separator); + if (parsed.PluginName is not null) + { + return parsed; + } + } + } + + return FunctionName.Parse(fullyQualifiedName); + } + private static void TrackStreamingFunctionCallUpdate(StreamingFunctionCallUpdateContent update, ref Dictionary? functionCallIdsByIndex, ref Dictionary? functionNamesByIndex, ref Dictionary? functionArgumentBuildersByIndex) { if (update is null) diff --git a/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallBuilder/FunctionCallContentBuilderTests.cs b/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallBuilder/FunctionCallContentBuilderTests.cs index e214478ce657..11c8c6907805 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallBuilder/FunctionCallContentBuilderTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Contents/FunctionCallBuilder/FunctionCallContentBuilderTests.cs @@ -212,6 +212,48 @@ public void ItShouldCaptureArgumentsDeserializationException(JsonSerializerOptio Assert.NotNull(functionCall.Exception); } + [Fact] + public void ItShouldParseUnderscoreSeparatorForOllamaAndGeminiConnectors() + { + // Arrange - Ollama/Gemini use underscore (e.g. time_ReadFile) per FullyQualifiedAIFunction + var sut = new FunctionCallContentBuilder(); + + // Act + var update1 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 1, functionCallIndex: 0, callId: "call_1", name: "time_ReadFile", arguments: null); + sut.Append(update1); + + var update2 = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 1, functionCallIndex: 0, callId: null, name: null, arguments: "{\"filePath\":\"d:/test.txt\"}"); + sut.Append(update2); + + var functionCalls = sut.Build(); + + // Assert + var functionCall = Assert.Single(functionCalls); + Assert.Equal("call_1", functionCall.Id); + Assert.Equal("time", functionCall.PluginName); + Assert.Equal("ReadFile", functionCall.FunctionName); + Assert.NotNull(functionCall.Arguments); + Assert.Equal("d:/test.txt", functionCall.Arguments["filePath"]); + } + + [Fact] + public void ItShouldParseDotSeparatorForFunctionChoiceBehavior() + { + // Arrange - FunctionChoiceBehavior uses dot (e.g. time.ReadFile) + var sut = new FunctionCallContentBuilder(); + + // Act + var update = CreateStreamingContentWithFunctionCallUpdate(choiceIndex: 1, functionCallIndex: 0, callId: "call_1", name: "time.ReadFile", arguments: null); + sut.Append(update); + + var functionCalls = sut.Build(); + + // Assert + var functionCall = Assert.Single(functionCalls); + Assert.Equal("time", functionCall.PluginName); + Assert.Equal("ReadFile", functionCall.FunctionName); + } + private static StreamingChatMessageContent CreateStreamingContentWithFunctionCallUpdate(int choiceIndex, int functionCallIndex, string? callId, string? name, string? arguments, int requestIndex = 0) { var content = new StreamingChatMessageContent(AuthorRole.Assistant, null);