diff --git a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs index 42c4b7f6e6a9..4b0518814c9f 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Functions/KernelFunctionLogMessages.cs @@ -161,7 +161,17 @@ private static void LogFunctionResultValueInternal(this ILogger logger, string? } catch (NotSupportedException ex) { - s_logFunctionResultValue(logger, pluginName, functionName, "Failed to log function result value", ex); + // Fall back to ToString() when JSON serialization isn't supported for this type + // (e.g. Microsoft.Extensions.AI.TextContent is not registered in AbstractionsJsonContext) + try + { + var toStringValue = resultValue?.Value?.ToString() ?? string.Empty; + s_logFunctionResultValue(logger, pluginName, functionName, toStringValue, null); + } + catch + { + s_logFunctionResultValue(logger, pluginName, functionName, "Failed to log function result value", ex); + } } } } diff --git a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs index ec2642fa12e1..2e618b79c96f 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Functions/KernelFunctionLogMessagesTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; @@ -48,9 +49,46 @@ public void ItShouldLogFunctionResultOfAnyType(Type resultType) It.IsAny>())); } + [Fact] + public void ItShouldFallBackToToStringWhenJsonSerializationIsNotSupported() + { + // Arrange + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + + // TypeNotInJsonContext cannot be cast to string and is not registered in the restricted JSON context + var unserializableValue = new TypeNotInJsonContext(); + var functionResult = new FunctionResult(KernelFunctionFactory.CreateFromMethod(() => { }), unserializableValue); + + // Use a restricted JsonSerializerOptions that knows about object but not TypeNotInJsonContext, + // simulating the AOT scenario where AbstractionsJsonContext is used and an unregistered + // MEAItype (e.g. Microsoft.Extensions.AI.TextContent) is returned from an MCP tool. + var restrictedOptions = RestrictedJsonContext.Default.Options; + + // Act + logger.Object.LogFunctionResultValue("p1", "f1", functionResult, restrictedOptions); + + // Assert - ToString() fallback should have been used, not the error message + logger.Verify(l => l.Log( + LogLevel.Trace, + 0, + It.Is((o, _) => o.ToString() == "Function p1-f1 result: TypeNotInJsonContext()"), + null, + It.IsAny>())); + } + private sealed class User { [JsonPropertyName("name")] public string? Name { get; set; } } + + private sealed class TypeNotInJsonContext + { + public override string ToString() => "TypeNotInJsonContext()"; + } + + [JsonSerializable(typeof(IDictionary))] + [JsonSerializable(typeof(object))] + private sealed partial class RestrictedJsonContext : JsonSerializerContext { } } diff --git a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py index 62fb798bb11b..b679206e4993 100644 --- a/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py +++ b/python/semantic_kernel/agents/azure_ai/agent_thread_actions.py @@ -8,6 +8,8 @@ from azure.ai.agents.models import ( AgentsNamedToolChoiceType, AgentStreamEvent, + AgentsResponseFormat, + AgentsResponseFormatMode, AsyncAgentEventHandler, AsyncAgentRunStream, BaseAsyncAgentEventHandler, @@ -923,7 +925,7 @@ def _generate_options(cls: type[_T], **kwargs: Any) -> dict[str, Any]: return { "model": merged.get("model"), "top_p": merged.get("top_p"), - "response_format": merged.get("response_format"), + "response_format": cls._coerce_response_format(merged.get("response_format")), "temperature": merged.get("temperature"), "truncation_strategy": truncation_strategy, "metadata": merged.get("metadata"), @@ -933,6 +935,28 @@ def _generate_options(cls: type[_T], **kwargs: Any) -> dict[str, Any]: "additional_messages": additional_messages, } + @staticmethod + def _coerce_response_format( + response_format: Any, + ) -> "str | AgentsResponseFormatMode | AgentsResponseFormat | ResponseFormatJsonSchemaType | None": + """Coerce a plain dict response_format to the appropriate Azure AI typed model. + + When users supply ``response_format`` as a plain Python :class:`dict` (e.g. + ``{"type": "json_object"}``), the Azure AI telemetry instrumentor raises + ``ValueError: Unknown response format `` because it only handles + the typed SDK models. This method converts plain dicts to the correct type so + that the Azure AI telemetry layer can serialize them without error. + """ + if response_format is None or isinstance( + response_format, (str, AgentsResponseFormatMode, AgentsResponseFormat, ResponseFormatJsonSchemaType) + ): + return response_format + if isinstance(response_format, dict): + if response_format.get("type") == "json_schema": + return ResponseFormatJsonSchemaType(response_format) + return AgentsResponseFormat(response_format) + return response_format + @classmethod def _translate_additional_messages( cls: type[_T], messages: "list[ChatMessageContent] | None" diff --git a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py index 000491d09021..66bbbd1d0c75 100644 --- a/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py +++ b/python/tests/unit/agents/azure_ai_agent/test_agent_thread_actions.py @@ -3,11 +3,15 @@ from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch +import pytest from azure.ai.agents.models import ( + AgentsResponseFormat, + AgentsResponseFormatMode, MessageTextContent, MessageTextDetails, RequiredFunctionToolCall, RequiredFunctionToolCallDetails, + ResponseFormatJsonSchemaType, RunStep, RunStepCodeInterpreterToolCall, RunStepCodeInterpreterToolCallDetails, @@ -354,3 +358,52 @@ async def test_agent_thread_actions_invoke_stream(ai_project_client, ai_agent_de collected_messages.append(content) assert isinstance(content, ChatMessageContent) assert content.metadata.get("message_id") == "msg_1" + + +# region _coerce_response_format tests + + +@pytest.mark.parametrize( + "input_value, expected_type", + [ + (None, type(None)), + ("auto", str), + (AgentsResponseFormatMode.AUTO, AgentsResponseFormatMode), + (AgentsResponseFormat({"type": "json_object"}), AgentsResponseFormat), + ( + ResponseFormatJsonSchemaType({"type": "json_schema", "json_schema": {"name": "t", "schema": {}}}), + ResponseFormatJsonSchemaType, + ), + ], +) +def test_coerce_response_format_passthrough(input_value, expected_type): + """Values that are already the correct type should be returned unchanged.""" + result = AgentThreadActions._coerce_response_format(input_value) + assert isinstance(result, expected_type) or result is None + + +def test_coerce_response_format_dict_json_object(): + """A plain dict with type 'json_object' should become AgentsResponseFormat.""" + result = AgentThreadActions._coerce_response_format({"type": "json_object"}) + assert isinstance(result, AgentsResponseFormat) + + +def test_coerce_response_format_dict_json_schema(): + """A plain dict with type 'json_schema' should become ResponseFormatJsonSchemaType.""" + payload = {"type": "json_schema", "json_schema": {"name": "MySchema", "strict": True, "schema": {}}} + result = AgentThreadActions._coerce_response_format(payload) + assert isinstance(result, ResponseFormatJsonSchemaType) + + +def test_coerce_response_format_unknown_type_passthrough(): + """An unknown (non-dict) type should be returned as-is to let the SDK handle it.""" + + class Custom: + pass + + custom = Custom() + result = AgentThreadActions._coerce_response_format(custom) + assert result is custom + + +# endregion