diff --git a/dotnet/src/InternalUtilities/meai/Extensions/ChatMessageExtensions.cs b/dotnet/src/InternalUtilities/meai/Extensions/ChatMessageExtensions.cs index b82bfb61f577..2598e961d559 100644 --- a/dotnet/src/InternalUtilities/meai/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/InternalUtilities/meai/Extensions/ChatMessageExtensions.cs @@ -35,14 +35,8 @@ internal static ChatMessageContent ToChatMessageContent(this ChatMessage message Microsoft.Extensions.AI.UriContent uc when uc.HasTopLevelMediaType("audio") => new Microsoft.SemanticKernel.AudioContent(uc.Uri), Microsoft.Extensions.AI.DataContent dc => new Microsoft.SemanticKernel.BinaryContent(dc.Uri), Microsoft.Extensions.AI.UriContent uc => new Microsoft.SemanticKernel.BinaryContent(uc.Uri), - Microsoft.Extensions.AI.FunctionCallContent fcc => new Microsoft.SemanticKernel.FunctionCallContent( - functionName: fcc.Name, - id: fcc.CallId, - arguments: fcc.Arguments is not null ? new(fcc.Arguments) : null), - Microsoft.Extensions.AI.FunctionResultContent frc => new Microsoft.SemanticKernel.FunctionResultContent( - functionName: GetFunctionCallContent(frc.CallId)?.Name, - callId: frc.CallId, - result: frc.Result), + Microsoft.Extensions.AI.FunctionCallContent fcc => CreateFunctionCallContent(fcc), + Microsoft.Extensions.AI.FunctionResultContent frc => CreateFunctionResultContent(frc), _ => null }; #pragma warning restore SKEXP0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. @@ -58,6 +52,71 @@ internal static ChatMessageContent ToChatMessageContent(this ChatMessage message return result; + Microsoft.SemanticKernel.FunctionCallContent CreateFunctionCallContent(Microsoft.Extensions.AI.FunctionCallContent functionCallContent) + { + var (functionName, pluginName) = ParseFunctionName(functionCallContent.Name); + + return new Microsoft.SemanticKernel.FunctionCallContent( + functionName: functionName, + pluginName: pluginName, + id: functionCallContent.CallId, + arguments: functionCallContent.Arguments is not null ? new(functionCallContent.Arguments) : null); + } + + Microsoft.SemanticKernel.FunctionResultContent CreateFunctionResultContent(Microsoft.Extensions.AI.FunctionResultContent functionResultContent) + { + string? functionName = null; + string? pluginName = null; + + if (GetFunctionCallContent(functionResultContent.CallId) is { } functionCallContent) + { + (functionName, pluginName) = ParseFunctionName(functionCallContent.Name); + } + + return new Microsoft.SemanticKernel.FunctionResultContent( + functionName: functionName, + pluginName: pluginName, + callId: functionResultContent.CallId, + result: functionResultContent.Result); + } + + static (string FunctionName, string? PluginName) ParseFunctionName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return (string.Empty, null); + } + + // Most connectors use "." or "-" as the fully-qualified separator. + var parsed = FunctionName.Parse(name, "."); + if (!string.IsNullOrEmpty(parsed.PluginName)) + { + return (parsed.Name, parsed.PluginName); + } + + parsed = FunctionName.Parse(name, "-"); + if (!string.IsNullOrEmpty(parsed.PluginName)) + { + return (parsed.Name, parsed.PluginName); + } + + // Some Ollama models return tool names as "_". + int underscore = name.IndexOf('_'); + if (underscore > 0 && + underscore == name.LastIndexOf('_') && + underscore + 1 < name.Length && + char.IsUpper(name[underscore + 1])) + { + parsed = FunctionName.Parse(name, "_"); + if (!string.IsNullOrEmpty(parsed.PluginName)) + { + return (parsed.Name, parsed.PluginName); + } + } + + return (name, null); + } + Microsoft.Extensions.AI.FunctionCallContent? GetFunctionCallContent(string callId) => response?.Messages .Select(m => m.Contents diff --git a/dotnet/src/SemanticKernel.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/src/SemanticKernel.UnitTests/Extensions/ChatMessageExtensionsTests.cs index 982b4122d06e..481da00f1df7 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Extensions/ChatMessageExtensionsTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Extensions/ChatMessageExtensionsTests.cs @@ -200,6 +200,44 @@ public void ToChatMessageContentWithFunctionCallContentCreatesFunctionCallConten Assert.NotNull(functionCall.Arguments); } + [Fact] + public void ToChatMessageContentWithUnderscoreQualifiedFunctionCallParsesPluginAndFunction() + { + // Arrange + var chatMessage = new ChatMessage(ChatRole.Assistant, [ + new Microsoft.Extensions.AI.FunctionCallContent("call-123", "time_ReadFile") + ]); + + // Act + var result = chatMessage.ToChatMessageContent(); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Items); + var functionCall = Assert.IsType(result.Items[0]); + Assert.Equal("time", functionCall.PluginName); + Assert.Equal("ReadFile", functionCall.FunctionName); + } + + [Fact] + public void ToChatMessageContentWithSnakeCaseFunctionNameDoesNotSplitIntoPluginAndFunction() + { + // Arrange + var chatMessage = new ChatMessage(ChatRole.Assistant, [ + new Microsoft.Extensions.AI.FunctionCallContent("call-123", "my_function") + ]); + + // Act + var result = chatMessage.ToChatMessageContent(); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Items); + var functionCall = Assert.IsType(result.Items[0]); + Assert.Null(functionCall.PluginName); + Assert.Equal("my_function", functionCall.FunctionName); + } + [Fact] public void ToChatMessageContentWithFunctionResultContentCreatesFunctionResultContent() { @@ -224,6 +262,30 @@ public void ToChatMessageContentWithFunctionResultContentCreatesFunctionResultCo Assert.Equal("result value", functionResult.Result); } + [Fact] + public void ToChatMessageContentWithFunctionResultContentParsesPluginNameFromMatchedFunctionCall() + { + // Arrange + var functionCallMessage = new ChatMessage(ChatRole.Assistant, [ + new Microsoft.Extensions.AI.FunctionCallContent("call-123", "time_ReadFile") + ]); + var resultMessage = new ChatMessage(ChatRole.Tool, [ + new Microsoft.Extensions.AI.FunctionResultContent("call-123", "result value") + ]); + var response = new ChatResponse(new[] { functionCallMessage, resultMessage }); + + // Act + var result = resultMessage.ToChatMessageContent(response); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Items); + var functionResult = Assert.IsType(result.Items[0]); + Assert.Equal("time", functionResult.PluginName); + Assert.Equal("ReadFile", functionResult.FunctionName); + Assert.Equal("result value", functionResult.Result); + } + [Fact] public void ToChatMessageContentWithMultipleContentItemsCreatesMultipleItems() {