From 139f033b05596d2d2ec6a000da316ad1330573e2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Thu, 31 Jul 2025 12:35:29 +0100 Subject: [PATCH] .Net: Add Structured output ChatClientAgent samples (#250) * WIP * Structured Output sample * Update dotnet/samples/GettingStarted/Steps/Step06_ChatClientAgent_StructuredOutputs.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address xml and comment targeting the Structured Output context * Update with proposed fix for Persistent ChatClient * Address PR feedback --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Chris <66376200+crickman@users.noreply.github.com> --- dotnet/agent-framework-dotnet.slnx | 12 +-- ...tep06_ChatClientAgent_StructuredOutputs.cs | 75 +++++++++++++++++++ ...7_ChatClientAgent_UsingFileSearchTools.cs} | 2 +- .../NewPersistentAgentsChatClient.cs | 34 +++++++-- .../ChatClientAgentThreadTests.cs | 2 + 5 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 dotnet/samples/GettingStarted/Steps/Step06_ChatClientAgent_StructuredOutputs.cs rename dotnet/samples/GettingStarted/Steps/{Step04_ChatClientAgent_UsingFileSearchTools.cs => Step07_ChatClientAgent_UsingFileSearchTools.cs} (98%) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c75597230d..ea7898a16e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -29,12 +29,7 @@ - - - - - - + @@ -48,6 +43,11 @@ + + + + + diff --git a/dotnet/samples/GettingStarted/Steps/Step06_ChatClientAgent_StructuredOutputs.cs b/dotnet/samples/GettingStarted/Steps/Step06_ChatClientAgent_StructuredOutputs.cs new file mode 100644 index 0000000000..c49c081316 --- /dev/null +++ b/dotnet/samples/GettingStarted/Steps/Step06_ChatClientAgent_StructuredOutputs.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI.Agents; + +namespace Steps; + +/// +/// Demonstrates how to use structured outputs with . +/// +public sealed class Step06_ChatClientAgent_StructuredOutputs(ITestOutputHelper output) : AgentSample(output) +{ + /// + /// Demonstrates processing structured outputs using JSON schemas to extract information about a person. + /// + [Theory] + [InlineData(ChatClientProviders.AzureAIAgentsPersistent)] + [InlineData(ChatClientProviders.AzureOpenAI)] + [InlineData(ChatClientProviders.OpenAIAssistant)] + [InlineData(ChatClientProviders.OpenAIChatCompletion)] + [InlineData(ChatClientProviders.OpenAIResponses)] + public async Task RunWithCustomSchema(ChatClientProviders provider) + { + var agentOptions = new ChatClientAgentOptions(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + agentOptions.ChatOptions = new() + { + ResponseFormat = ChatResponseFormatJson.ForJsonSchema( + schema: AIJsonUtilities.CreateJsonSchema(typeof(PersonInfo)), + schemaName: "PersonInfo", + schemaDescription: "Information about a person including their name, age, and occupation" + ) + }; + + // Create the server-side agent Id when applicable (depending on the provider). + agentOptions.Id = await base.AgentCreateAsync(provider, agentOptions); + + using var chatClient = base.GetChatClient(provider, agentOptions); + + ChatClientAgent agent = new(chatClient, agentOptions); + + var thread = agent.GetNewThread(); + + const string Prompt = "Please provide information about John Smith, who is a 35-year-old software engineer."; + + var updates = agent.RunStreamingAsync(Prompt, thread); + var agentResponse = await updates.ToAgentRunResponseAsync(); + + var personInfo = agentResponse.Deserialize(JsonSerializerOptions.Web); + + Console.WriteLine("Assistant Output:"); + Console.WriteLine($"Name: {personInfo.Name}"); + Console.WriteLine($"Age: {personInfo.Age}"); + Console.WriteLine($"Occupation: {personInfo.Occupation}"); + + // Clean up the server-side agent after use when applicable (depending on the provider). + await base.AgentCleanUpAsync(provider, agent, thread); + } + + /// + /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// + public class PersonInfo + { + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("age")] + public int? Age { get; set; } + + [JsonPropertyName("occupation")] + public string? Occupation { get; set; } + } +} diff --git a/dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs b/dotnet/samples/GettingStarted/Steps/Step07_ChatClientAgent_UsingFileSearchTools.cs similarity index 98% rename from dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs rename to dotnet/samples/GettingStarted/Steps/Step07_ChatClientAgent_UsingFileSearchTools.cs index 7cb8f78a1d..a4fcf2570e 100644 --- a/dotnet/samples/GettingStarted/Steps/Step04_ChatClientAgent_UsingFileSearchTools.cs +++ b/dotnet/samples/GettingStarted/Steps/Step07_ChatClientAgent_UsingFileSearchTools.cs @@ -15,7 +15,7 @@ namespace Steps; /// Demonstrates how to use with file search tools and file references. /// Shows uploading files to different providers and using them with file search capabilities to retrieve and analyze information from documents. /// -public sealed class Step04_ChatClientAgent_UsingFileSearchTools(ITestOutputHelper output) : AgentSample(output) +public sealed class Step07_ChatClientAgent_UsingFileSearchTools(ITestOutputHelper output) : AgentSample(output) { [Theory] [InlineData(ChatClientProviders.AzureAIAgentsPersistent)] diff --git a/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/NewPersistentAgentsChatClient.cs b/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/NewPersistentAgentsChatClient.cs index e86a75d0c9..5507171516 100644 --- a/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/NewPersistentAgentsChatClient.cs +++ b/dotnet/src/Microsoft.Extensions.AI.Agents.AzureAI/NewPersistentAgentsChatClient.cs @@ -371,13 +371,35 @@ namespace Azure.AI.Agents.Persistent { if (options.ResponseFormat is ChatResponseFormatJson jsonFormat) { - runOptions.ResponseFormat = jsonFormat.Schema is { } schema ? - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(new Dictionary() + if (jsonFormat.Schema is JsonElement schema) + { + var schemaNode = JsonSerializer.SerializeToNode(schema, AgentsChatClientJsonContext.Default.JsonElement)!; + + var jsonSchemaObject = new JsonObject { - ["type"] = "json_schema", - ["json_schema"] = JsonSerializer.SerializeToNode(schema, AgentsChatClientJsonContext.Default.JsonNode), - }, AgentsChatClientJsonContext.Default.JsonObject)) : - BinaryData.FromString("""{ "type": "json_object" }"""); + ["schema"] = schemaNode + }; + + if (jsonFormat.SchemaName is not null) + { + jsonSchemaObject["name"] = jsonFormat.SchemaName; + } + if (jsonFormat.SchemaDescription is not null) + { + jsonSchemaObject["description"] = jsonFormat.SchemaDescription; + } + + runOptions.ResponseFormat = + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(new() + { + ["type"] = "json_schema", + ["json_schema"] = jsonSchemaObject, + }, AgentsChatClientJsonContext.Default.JsonObject)); + } + else + { + runOptions.ResponseFormat = BinaryData.FromString("""{ "type": "json_object" }"""); + } } } } diff --git a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs index 587f5adf5e..07ed61ceb0 100644 --- a/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs +++ b/dotnet/tests/Microsoft.Extensions.AI.Agents.UnitTests/ChatCompletion/ChatClientAgentThreadTests.cs @@ -879,7 +879,9 @@ public class ChatClientAgentThreadTests public void VerifyJsonDeserialization_HandlesMalformedJson() { // Arrange - Invalid JSON structure +#pragma warning disable JSON001 // Invalid JSON pattern string invalidJson = "{ invalid json"; +#pragma warning restore JSON001 // Invalid JSON pattern // Act & Assert Assert.Throws(() => JsonSerializer.Deserialize(invalidJson));