diff --git a/docs/decisions/0016-structured-output.md b/docs/decisions/0016-structured-output.md new file mode 100644 index 0000000000..4fdae3c77e --- /dev/null +++ b/docs/decisions/0016-structured-output.md @@ -0,0 +1,658 @@ +--- +status: proposed +contact: sergeymenshykh +date: 2026-01-22 +deciders: rbarreto, westey-m, stephentoub +informed: {} +--- + +# Structured Output + +Structured output is a valuable aspect of any agent system, since it forces an agent to produce output in a required format that may include required fields. +This allows easily turning unstructured data into structured data using a general-purpose language model. + +## Context and Problem Statement + +Structured output is currently supported only by `ChatClientAgent` and can be configured in two ways: + +**Approach 1: ResponseFormat + Deserialize** + +Specify the SO type schema via the `ChatClientAgent{Run}Options.ChatOptions.ResponseFormat` property at agent creation or invocation time, then use `JsonSerializer.Deserialize` to extract the structured data from the response text. + + ```csharp + // SO type can be provided at agent creation time + ChatClientAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions() + { + Name = "...", + ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema() } + }); + + AgentResponse response = await agent.RunAsync("..."); + + PersonInfo personInfo = response.Deserialize(JsonSerializerOptions.Web); + + Console.WriteLine($"Name: {personInfo.Name}"); + Console.WriteLine($"Age: {personInfo.Age}"); + Console.WriteLine($"Occupation: {personInfo.Occupation}"); + + // Alternatively, SO type can be provided at agent invocation time + response = await agent.RunAsync("...", new ChatClientAgentRunOptions() + { + ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema() } + }); + + personInfo = response.Deserialize(JsonSerializerOptions.Web); + + Console.WriteLine($"Name: {personInfo.Name}"); + Console.WriteLine($"Age: {personInfo.Age}"); + Console.WriteLine($"Occupation: {personInfo.Occupation}"); + ``` + +**Approach 2: Generic RunAsync** + +Supply the SO type as a generic parameter to `RunAsync` and access the parsed result directly via the `Result` property. + + ```csharp + ChatClientAgent agent = ...; + + AgentResponse response = await agent.RunAsync("..."); + + Console.WriteLine($"Name: {response.Result.Name}"); + Console.WriteLine($"Age: {response.Result.Age}"); + Console.WriteLine($"Occupation: {response.Result.Occupation}"); + ``` + Note: `RunAsync` is an instance method of `ChatClientAgent` and not part of the `AIAgent` base class since not all agents support structured output. + +Approach 1 is perceived as cumbersome by the community, as it requires additional effort when using primitive or collection types - the SO schema may need to be wrapped in an artificial JSON object. Otherwise, the caller will encounter an error like _Invalid schema for response_format 'Movie': schema must be a JSON Schema of 'type: "object"', got 'type: "array"'_. +This occurs because OpenAI and compatible APIs require a JSON object as the root schema. + +Approach 1 is also necessary in scenarios where (a) agents can only be configured with SO at creation time (such as with `AIProjectClient`), (b) the SO type is not known at compile time, or (c) the JSON schema is represented as text (for declarative agents) or as a `JsonElement`. + +Approach 2 is more convenient and works seamlessly with primitives and collections. However, it requires the SO type to be known at compile time, making it less flexible. + +Additionally, since the `RunAsync` methods are instance methods of `ChatClientAgent` and are not part of the `AIAgent` base class, applying decorators like `OpenTelemetryAgent` on top of `ChatClientAgent` prevents users from accessing `RunAsync`, meaning structured output is not available with decorated agents. + +Given the different scenarios above in which structured output can be used, there is no one-size-fits-all solution. Each approach has its own advantages and limitations, +and the two can complement each other to provide a comprehensive structured output experience across various use cases. + +## Approaches Overview + +1. SO usage via `ResponseFormat` property +2. SO usage via `RunAsync` generic method + +## 1. SO usage via `ResponseFormat` property + +This approach should be used in the following scenarios: + - 1.1 SO result as text is sufficient as is, and deserialization is not required + - 1.2 SO for inter-agent collaboration + - 1.3 SO can only be configured at agent creation time (such as with `AIProjectClient`) + - 1.4 SO type is not known at compile time and represented by System.Type + - 1.5 SO is represented by JSON schema and there's no corresponding .NET type either at compile time or at runtime + - 1.6 SO in streaming scenarios, where the SO response is produced in parts + +**Note: Primitives and arrays are not supported by this approach.** + +When a caller provides a schema via `ResponseFormat`, they are explicitly telling the framework what schema to use. The framework passes that schema through as-is and +is not responsible for transforming it. Because the framework does not own the schema, it cannot wrap primitives or arrays into a JSON object to satisfy API requirements, +nor can it unwrap the response afterward - the caller controls the schema and is responsible for ensuring it is compatible with the underlying API. + +This is in contrast to the `RunAsync` approach (section 2), where the caller provides a type `T` and says "make it work." In that case, the caller does not +dictate the schema - the framework infers the schema from `T`, owns the end-to-end pipeline (schema generation, API invocation, and deserialization), and can +therefore wrap and unwrap primitives and arrays transparently. + +Additionally, in streaming scenarios (1.6), the framework cannot reliably unwrap a response it did not wrap, since it has no way of knowing whether the caller wrapped the schema.Wrapping and unwrapping can only be done safely when the framework owns the entire lifecycle - from schema creation through deserialization — which is only the case with `RunAsync`. + +If a caller needs to work with primitives or arrays via the `ResponseFormat` approach, they can easily create a wrapper type around them: + +```csharp +public class MovieListWrapper +{ + public List Movies { get; set; } +} +``` + +### 1.1 SO result as text is sufficient as is, and deserialization is not required + +In this scenario, the caller only needs the raw JSON text returned by the model and does not need to deserialize it into a .NET type. +The SO schema is specified via `ResponseFormat` at agent creation or invocation time, and the response text is consumed directly from the `AgentResponse`. + +```csharp +AIAgent agent = chatClient.AsAIAgent(); + +AgentRunOptions runOptions = new() +{ + ResponseFormat = ChatResponseFormat.ForJsonSchema() +}; + +AgentResponse response = await agent.RunAsync("...", options: runOptions); + +Console.WriteLine(response.Text); +``` + +### 1.2 SO for inter-agent collaboration + +This scenario assumes a multi-agent setup where agents collaborate by passing messages to each other. +One agent produces structured output as text that is then passed directly as input to the next agent, without intermediate deserialization. + +```csharp +// First agent extracts structured data from unstructured input +AIAgent extractionAgent = chatClient.AsAIAgent(new ChatClientAgentOptions() +{ + Name = "ExtractionAgent", + ChatOptions = new() + { + Instructions = "Extract person information from the provided text.", + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } +}); + +AgentResponse extractionResponse = await extractionAgent.RunAsync("John Smith is a 35-year-old software engineer."); + +// Pass the message with structured output text directly to the next agent +ChatMessage soMessage = extractionResponse.Messages.Last(); + +AIAgent summaryAgent = chatClient.AsAIAgent(new ChatClientAgentOptions() +{ + Name = "SummaryAgent", + ChatOptions = new() { Instructions = "Given the following structured person data, write a short professional bio." } +}); + +AgentResponse summaryResponse = await summaryAgent.RunAsync(soMessage); + +Console.WriteLine(summaryResponse); +``` + +### 1.3 SO configured at agent creation time + +In this scenario, the SO schema can only be configured at agent creation time (such as with `AIProjectClient`) and cannot be changed on a per-run basis. +The caller specifies the `ResponseFormat` when creating the agent, and all subsequent invocations use the same schema. + +```csharp +AIProjectClient client = ...; + +AIAgent agent = await client.CreateAIAgentAsync(model: "", new ChatClientAgentOptions() +{ + Name = "...", + ChatOptions = new() { ResponseFormat = ChatResponseFormat.ForJsonSchema() } +}); + +AgentResponse response = await agent.RunAsync("Please provide information about John Smith."); + +PersonInfo personInfo = JsonSerializer.Deserialize(response.Text, JsonSerializerOptions.Web)!; + +Console.WriteLine($"Name: {personInfo.Name}"); +Console.WriteLine($"Age: {personInfo.Age}"); +Console.WriteLine($"Occupation: {personInfo.Occupation}"); +``` + +### 1.4 SO type not known at compile time and represented by System.Type + +In this scenario, the SO type is not known at compile time and is provided as a `System.Type` at runtime. This is useful for dynamic scenarios where the schema is determined programmatically, +such as when building tooling or frameworks that work with user-defined types. + +```csharp +Type soType = GetStructuredOutputTypeFromConfiguration(); // e.g., typeof(PersonInfo) + +ChatResponseFormat responseFormat = ChatResponseFormat.ForJsonSchema(soType); + +AgentResponse response = await agent.RunAsync("...", new ChatClientAgentRunOptions() +{ + ChatOptions = new() { ResponseFormat = responseFormat } +}); + +PersonInfo personInfo = (PersonInfo)JsonSerializer.Deserialize(response.Text, soType, JsonSerializerOptions.Web)!; +``` + +### 1.5 SO represented by JSON schema with no corresponding .NET type + +In this scenario, the SO schema is represented as raw JSON schema text or a `JsonElement`, and there is no corresponding .NET type available at compile time or runtime. +This is typical for declarative agents or scenarios where schemas are loaded from external configuration. + +```csharp +// JSON schema provided as a string, e.g., loaded from a configuration file +string jsonSchema = """ +{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" }, + "occupation": { "type": "string" } + }, + "required": ["name", "age", "occupation"] +} +"""; + +ChatResponseFormat responseFormat = ChatResponseFormat.ForJsonSchema( + jsonSchemaName: "PersonInfo", + jsonSchema: BinaryData.FromString(jsonSchema)); + +AgentResponse response = await agent.RunAsync("...", new ChatClientAgentRunOptions() +{ + ChatOptions = new() { ResponseFormat = responseFormat } +}); + +// Consume the SO result as text since there's no .NET type to deserialize into +Console.WriteLine(response.Text); +``` + +### 1.6 SO in streaming scenarios + +In this scenario, the SO response is produced incrementally in parts via streaming. The caller specifies the `ResponseFormat` and consumes the response chunks as they arrive. +Deserialization is performed after all chunks have been received. + +```csharp +AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions() +{ + Name = "HelpfulAssistant", + ChatOptions = new() + { + Instructions = "You are a helpful assistant.", + ResponseFormat = ChatResponseFormat.ForJsonSchema() + } +}); + +IAsyncEnumerable updates = agent.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); + +AgentResponse response = await updates.ToAgentResponseAsync(); + +// Deserialize the complete SO result after streaming is finished +PersonInfo personInfo = JsonSerializer.Deserialize(response.Text)!; +``` + +## 2. SO usage via `RunAsync` generic method + +This approach provides a convenient way to work with structured output on a per-run basis when the target type is known at compile time and a typed instance of the result +is required. + +### Decision Drivers + +1. Support arrays and primitives as SO types +2. Support complex types as SO types +3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`) +4. Enable SO for all AI agents, regardless of whether they natively support it + +### Considered Options + +1. `RunAsync` as an instance method of `AIAgent` class delegating to virtual `RunCoreAsync` +2. `RunAsync` as an extension method using feature collection +3. `RunAsync` as a method of the new `ITypedAIAgent` interface +4. `RunAsync` as an instance method of `AIAgent` class working via the new `AgentRunOptions.ResponseFormat` property + +### 1. `RunAsync` as an instance method of `AIAgent` class delegating to virtual `RunCoreAsync` + +This option adds the `RunAsync` method directly to the `AIAgent` base class. + +```csharp +public abstract class AIAgent +{ + public Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + => this.RunCoreAsync(messages, session, serializerOptions, options, cancellationToken); + + protected virtual Task> RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new NotSupportedException($"The agent of type '{this.GetType().FullName}' does not support typed responses."); + } +} +``` + +Agents with native SO support override the `RunCoreAsync` method to provide their implementation. If not overridden, the method throws a `NotSupportedException`. + +Users will call the generic `RunAsync` method directly on the agent: + +```csharp +AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + +AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); +``` + +Decision drivers satisfied: +1. Support arrays and primitives as SO types +2. Support complex types as SO types +3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`) +4. Enable SO for all AI agents, regardless of whether they natively support it + +Pros: +- The `AIAgent.RunAsync` method is easily discoverable. +- Both the SO decorator and `ChatClientAgent` have compile-time access to the type `T`, allowing them to use the native `IChatClient.GetResponseAsync` API, which handles primitives and collections seamlessly. + +Cons: +- Agents without native SO support will still expose `RunAsync`, which may be misleading. +- `ChatClientAgent` exposing `RunAsync` may be misleading when the underlying chat client does not support SO. +- All `AIAgent` decorators must override `RunCoreAsync` to properly handle `RunAsync` calls. + +### 2. `RunAsync` as an extension method using feature collection + +This option uses the Agent Framework feature collection (implemented via `AgentRunOptions.AdditionalProperties`) to pass a `StructuredOutputFeature` to agents, signaling that SO is requested. + +Agents with native SO support check for this feature. If present, they read the target type, build the schema, invoke the underlying API, and store the response back in the feature. +```csharp +public class StructuredOutputFeature +{ + public StructuredOutputFeature(Type outputType) + { + this.OutputType = outputType; + } + + [JsonIgnore] + public Type OutputType { get; set; } + + public JsonSerializerOptions? SerializerOptions { get; set; } + + public AgentResponse? Response { get; set; } +} +``` + +The `RunAsync` extension method for `AIAgent` adds this feature to the collection. +```csharp +public static async Task> RunAsync( + this AIAgent agent, + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) +{ + // Create the structured output feature. + StructuredOutputFeature structuredOutputFeature = new(typeof(T)) + { + SerializerOptions = serializerOptions, + }; + + // Register it in the feature collection. + ((options ??= new AgentRunOptions()).AdditionalProperties ??= []).Add(typeof(StructuredOutputFeature).FullName!, structuredOutputFeature); + + var response = await agent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + if (structuredOutputFeature.Response is not null) + { + return new StructuredOutputResponse(structuredOutputFeature.Response, response, serializerOptions); + } + + throw new InvalidOperationException("No structured output response was generated by the agent."); +} +``` + +Users will call the `RunAsync` extension method directly on the agent: + +```csharp +AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + +AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); +``` + +Decision drivers satisfied: +1. Support arrays and primitives as SO types +2. Support complex types as SO types +3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`) +4. Enable SO for all AI agents, regardless of whether they natively support it + +Pros: +- The `RunAsync` extension method is easily discoverable. +- The `AIAgent` public API surface remains unchanged. +- No changes required to `AIAgent` decorators. + +Cons: +- Agents without native SO support will still expose `RunAsync`, which may be misleading. +- `ChatClientAgent` exposing `RunAsync` may be misleading when the underlying chat client does not support SO. + +### 3. `RunAsync` as a method of the new `ITypedAIAgent` interface + +This option defines a new `ITypedAIAgent` interface that agents with SO support implement. Agents without SO support do not implement it, allowing users to check for SO capability via interface detection. + +The interface: +```csharp +public interface ITypedAIAgent +{ + Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default); + + ... +} +``` + +Agents with SO support implement this interface: +```csharp +public sealed partial class ChatClientAgent : AIAgent, ITypedAIAgent +{ + public async Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + ... + } +} +``` + +However, `ChatClientAgent` presents a challenge: it can work with chat clients that either support or do not support SO. Implementing the interface does not guarantee +the underlying chat client supports SO, which undermines the core idea of using interface detection to determine SO capability. + +Additionally, to allow users to access interface methods on decorated agents, all decorators must implement `ITypedAIAgent`. This makes it difficult for users to +determine whether the underlying agent actually supports SO, further weakening the purpose of this approach. + +Furthermore, users would have to probe the agent type to check if it implements the `ITypedAIAgent` interface and cast it accordingly to access the `RunAsync` methods. +This adds friction to the user experience. A `RunAsync` extension method for `AIAgent` could be provided to alleviate that. + +Given these drawbacks, this option is more complex to implement than the others without providing clear benefits. + +Decision drivers satisfied: +1. Support arrays and primitives as SO types +2. Support complex types as SO types +3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`) +4. Enable SO for all AI agents, regardless of whether they natively support it + +Pros: +- Both the SO decorator and `ChatClientAgent` have compile-time access to the type `T`, allowing them to use the native `IChatClient.GetResponseAsync` API, which handles primitives and collections seamlessly. + +Cons: +- `ChatClientAgent` implementing `ITypedAIAgent` may be misleading when the underlying chat client does not support SO. +- All `AIAgent` decorators must implement `ITypedAIAgent` to handle `RunAsync` calls. +- Decorators implementing the interface may mislead users into thinking the underlying agent natively supports SO. +- Agents must implement all members of `ITypedAIAgent`, not just a core method. +- Users must check the agent type and cast to `ITypedAIAgent` to access `RunAsync`. + +### 4. `RunAsync` as an instance method of `AIAgent` class working via the new `AgentRunOptions.ResponseFormat` property + +This option adds a `ResponseFormat` property of type `ChatResponseFormat` to `AgentRunOptions`. Agents that support SO check for the presence of +this property in the options passed to `RunAsync` to determine whether structured output is requested. If present, they use the schema from `ResponseFormat` +to invoke the underlying API and obtain the SO response. + +```csharp +public class AgentRunOptions +{ + public ChatResponseFormat? ResponseFormat { get; set; } +} +``` + +Additionally, a generic `RunAsync` method is added to `AIAgent` that initializes the `ResponseFormat` based on the type `T` and delegates to the non-generic `RunAsync`. + +```csharp +public abstract class AIAgent +{ + public async Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; + + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); + + options = options?.Clone() ?? new AgentRunOptions(); + options.ResponseFormat = responseFormat; + + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + return new AgentResponse(response, serializerOptions); + } +} +``` + +Users call the generic `RunAsync` method directly on the agent: + +```csharp +AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + +AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); +``` + +Decision drivers satisfied: +1. Support arrays and primitives as SO types +2. Support complex types as SO types +3. Work with `AIAgent` decorators (e.g., `OpenTelemetryAgent`) +4. Enable SO for all AI agents, regardless of whether they natively support it + +Pros: +- The `AIAgent.RunAsync` method is easily discoverable. +- No changes required to `AIAgent` decorators + +Cons: +- Agents without native SO support will still expose `RunAsync`, which may be misleading. +- `ChatClientAgent` exposing `RunAsync` may be misleading when the underlying chat client does not support SO. + +### Decision Table + +| | Option 1: Instance method + RunCoreAsync | Option 2: Extension method + feature collection | Option 3: ITypedAIAgent Interface | Option 4: Instance method + AgentRunOptions.ResponseFormat | +|---|---|---|---|---| +| Discoverability | ✅ `RunAsync` easily discoverable | ✅ `RunAsync` easily discoverable | ❌ Requires type check and cast | ✅ `RunAsync` easily discoverable | +| Decorator changes | ❌ All decorators must override `RunCoreAsync` | ✅ No changes required | ❌ All decorators must implement `ITypedAIAgent` | ✅ No changes required to decorators | +| Primitives/collections handling | ✅ Native support via `IChatClient.GetResponseAsync` | ❌ Must wrap/unwrap internally | ✅ Native support via `IChatClient.GetResponseAsync` | ❌ Must wrap/unwrap internally | +| Misleading API exposure | ❌ Agents without SO still expose `RunAsync` | ❌ Agents without SO still expose `RunAsync` | ❌ Interface on `ChatClientAgent` may be misleading | ❌ Agents without SO still expose `RunAsync` | +| Implementation burden | ❌ Decorators must override method | ❌ Must handle schema wrapping | ❌ Agents must implement all interface members | ✅ Delegates to existing `RunAsync` via `ResponseFormat` | + +## Cross-Cutting Aspects + +1. **The `useJsonSchemaResponseFormat` parameter**: The `ChatClientAgent.RunAsync` method has this parameter to enable structured output on LLMs that do not natively support it. + It works by adding a user message like "Respond with a JSON value conforming to the following schema:" along with the JSON schema. However, this approach has not been reliable historically. The recommendation is not to carry this parameter forward, regardless of which option is chosen. + +2. **Primitives and array types handling**: There are a few options for how primitive and array types can be handled in the Agent Framework: + + 1. **Never wrap**, regardless of whether the schema is provided via `ResponseFormat` or `RunAsync`. + - Pro: No changes needed; user has full control. + - Pro: No issues with unwrapping in streaming scenarios. + - Con: User must wrap manually. + + 2. **Always wrap**, regardless of whether the schema is provided via `ResponseFormat` or `RunAsync`. + - Pro: Consistent wrapping behavior; no manual wrapping needed. + - Con: Inconsistent unwrapping behavior; it may be unexpected to have SO result wrapped when schema is provided via `ResponseFormat`. + - Con: Impossible to know if SO result is wrapped to unwrap it in streaming scenarios. + + 3. **Wrap only for `RunAsync`** and do not wrap the schema provided via `ResponseFormat`. + - Pro: No unexpectedly wrapped result when schema is provided via `ResponseFormat`. + - Pro: Solves the problem with unwrapping in streaming scenarios. + + 4. **User decides** whether to wrap schema provided via `ResponseFormat` using a new `wrapPrimitivesAndArrays` property of `ChatResponseFormatJson`. For SO provided via `RunAsync`, AF always wraps. + - Pro: No manual wrapping needed; just flip a switch. + - Pro: Solves the problem with unwrapping in streaming scenarios. + - Con: Extends the public API surface. + +3. **Structured output for agents without native SO support**: Some AI agents in AF do not support structured output natively. This is either because it is not part of the protocol (e.g., A2A agent) or because the agents use LLMs without structured output capabilities. + To address this gap, AF can provide the `StructuredOutputAgent` decorator. This decorator wraps any `AIAgent` and adds structured output support by obtaining the text response from the decorated agent and delegating it to a configured chat client for JSON transformation. + + ```csharp + public class StructuredOutputAgent : DelegatingAIAgent + { + private readonly IChatClient _chatClient; + + public StructuredOutputAgent(AIAgent innerAgent, IChatClient chatClient) + : base(innerAgent) + { + this._chatClient = Throw.IfNull(chatClient); + } + + protected override async Task> RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + // Run the inner agent first, to get back the text response we want to convert. + var textResponse = await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + // Invoke the chat client to transform the text output into structured data. + ChatResponse soResponse = await this._chatClient.GetResponseAsync( + messages: + [ + new ChatMessage(ChatRole.System, "You are a json expert and when provided with any text, will convert it to the requested json format."), + new ChatMessage(ChatRole.User, textResponse.Text) + ], + serializerOptions: serializerOptions ?? AgentJsonUtilities.DefaultOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + return new StructuredOutputAgentResponse(soResponse, textResponse); + } + } + ``` + + The decorator preserves the original response from the decorated agent and surfaces it via the `OriginalResponse` property on the returned `StructuredOutputAgentResponse`. + This allows users to access both the original unstructured response and the new structured response when using this decorator. + ```csharp + public class StructuredOutputAgentResponse : AgentResponse + { + internal StructuredOutputAgentResponse(ChatResponse chatResponse, AgentResponse agentResponse) : base(chatResponse) + { + this.OriginalResponse = agentResponse; + } + + public AgentResponse OriginalResponse { get; } + } + ``` + + The decorator can be registered during the agent configuration step using the `UseStructuredOutput` extension method on `AIAgentBuilder`. + + ```csharp + IChatClient meaiChatClient = chatClient.AsIChatClient(); + + AIAgent baseAgent = meaiChatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + + // Register the StructuredOutputAgent decorator during agent building + AIAgent agent = baseAgent + .AsBuilder() + .UseStructuredOutput(meaiChatClient) + .Build(); + + AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); + + Console.WriteLine($"Name: {response.Result.Name}"); + Console.WriteLine($"Age: {response.Result.Age}"); + Console.WriteLine($"Occupation: {response.Result.Occupation}"); + + var originalResponse = ((StructuredOutputAgentResponse)response.RawRepresentation!).OriginalResponse; + Console.WriteLine($"Original unstructured response: {originalResponse.Text}"); + + ``` + +## Decision Outcome + +It was decided to keep both approaches for structured output - via `ResponseFormat` and via `RunAsync` since they serve different scenarios and use cases. + +For the `RunAsync` approach, option 4 was selected, which adds a generic `RunAsync` method to `AIAgent` that works via the new `AgentRunOptions.ResponseFormat` property. +This was chosen for its simplicity and because no changes are required to existing `AIAgent` decorators. + +For cross-cutting aspects, the `useJsonSchemaResponseFormat` parameter will not be carried forward due to reliability issues. + +For handling primitives and array types, option 3 was selected: wrap only for `RunAsync` and do not wrap the schema provided via `ResponseFormat`. +This avoids the issues described in the Approach 1 section note. + +Finally, it was decided not to include the `StructuredOutputAgent` decorator in the framework, since the reliability of producing structured output via an additional +LLM call may not be sufficient for all scenarios. Instead, this pattern is provided as a sample to demonstrate how structured output can be achieved for agents without native support, +giving users a reference implementation they can adapt to their own requirements. \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index a52e026a8f..e592e80803 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -389,6 +389,9 @@ + + + diff --git a/dotnet/eng/MSBuild/Shared.props b/dotnet/eng/MSBuild/Shared.props index da8806a1f3..95eb5e13da 100644 --- a/dotnet/eng/MSBuild/Shared.props +++ b/dotnet/eng/MSBuild/Shared.props @@ -20,4 +20,7 @@ + + + diff --git a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs index 51f4791272..af1a54b103 100644 --- a/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs +++ b/dotnet/samples/AGUIClientServer/AGUIDojoServer/SharedState/SharedStateAgent.cs @@ -78,7 +78,7 @@ protected override async IAsyncEnumerable RunCoreStreamingA var response = allUpdates.ToAgentResponse(); - if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot)) + if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot)) { byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( stateSnapshot, @@ -103,4 +103,25 @@ protected override async IAsyncEnumerable RunCoreStreamingA yield return update; } } + + private static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) + { + try + { + T? result = JsonSerializer.Deserialize(json, jsonSerializerOptions); + if (result is null) + { + structuredOutput = default!; + return false; + } + + structuredOutput = result; + return true; + } + catch + { + structuredOutput = default!; + return false; + } + } } diff --git a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs index 66e8da6eed..17bde7d215 100644 --- a/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs +++ b/dotnet/samples/GettingStarted/AGUI/Step05_StateManagement/Server/SharedStateAgent.cs @@ -107,7 +107,7 @@ stateObj is not JsonElement state || var response = allUpdates.ToAgentResponse(); // Try to deserialize the structured state response - if (response.TryDeserialize(this._jsonSerializerOptions, out JsonElement stateSnapshot)) + if (TryDeserialize(response.Text, this._jsonSerializerOptions, out JsonElement stateSnapshot)) { // Serialize and emit as STATE_SNAPSHOT via DataContent byte[] stateBytes = JsonSerializer.SerializeToUtf8Bytes( @@ -134,4 +134,25 @@ stateObj is not JsonElement state || yield return update; } } + + private static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) + { + try + { + T? deserialized = JsonSerializer.Deserialize(json, jsonSerializerOptions); + if (deserialized is null) + { + structuredOutput = default!; + return false; + } + + structuredOutput = deserialized; + return true; + } + catch + { + structuredOutput = default!; + return false; + } + } } diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/AIAgentBuilderExtensions.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/AIAgentBuilderExtensions.cs new file mode 100644 index 0000000000..987869e175 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/AIAgentBuilderExtensions.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace SampleApp; + +/// +/// Provides extension methods for adding structured output capabilities to instances. +/// +internal static class AIAgentBuilderExtensions +{ + /// + /// Adds structured output capabilities to the agent pipeline, enabling conversion of text responses to structured JSON format. + /// + /// The to which structured output support will be added. + /// + /// The chat client used to transform text responses into structured JSON format. + /// If , the chat client will be resolved from the service provider. + /// + /// + /// An optional factory function that returns the instance to use. + /// This allows for fine-tuning the structured output behavior such as setting the response format or system message. + /// + /// The with structured output capabilities added, enabling method chaining. + /// + /// + /// A must be specified either through the + /// at runtime or the + /// provided during configuration. + /// + /// + public static AIAgentBuilder UseStructuredOutput( + this AIAgentBuilder builder, + IChatClient? chatClient = null, + Func? optionsFactory = null) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.Use((innerAgent, services) => + { + chatClient ??= services?.GetService() + ?? throw new InvalidOperationException($"No {nameof(IChatClient)} was provided and none could be resolved from the service provider. Either provide an {nameof(IChatClient)} explicitly or register one in the dependency injection container."); + + return new StructuredOutputAgent(innerAgent, chatClient, optionsFactory?.Invoke()); + }); + } +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs index 851b3340d5..7e74315e7d 100644 --- a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/Program.cs @@ -8,11 +8,13 @@ using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; using OpenAI.Chat; using SampleApp; +using ChatMessage = Microsoft.Extensions.AI.ChatMessage; -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; +string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; // Create chat client to be used by chat client agents. // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. @@ -23,52 +25,159 @@ new DefaultAzureCredential()) .GetChatClient(deploymentName); -// Create the ChatClientAgent with the specified name and instructions. -ChatClientAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); +// Demonstrates how to work with structured output via ResponseFormat with the non-generic RunAsync method. +// This approach is useful when: +// a. Structured output is used for inter-agent communication, where one agent produces structured output +// and passes it as text to another agent as input, without the need for the caller to directly work with the structured output. +// b. The type of the structured output is not known at compile time, so the generic RunAsync method cannot be used. +// c. The type of the structured output is represented by JSON schema only, without a corresponding class or type in the code. +await UseStructuredOutputWithResponseFormatAsync(chatClient); -// Set PersonInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke the agent with some unstructured input. -AgentResponse response = await agent.RunAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); +// Demonstrates how to work with structured output via the generic RunAsync method. +// This approach is useful when the caller needs to directly work with the structured output in the code +// via an instance of the corresponding class or type and the type is known at compile time. +await UseStructuredOutputWithRunAsync(chatClient); -// Access the structured output via the Result property of the agent response. -Console.WriteLine("Assistant Output:"); -Console.WriteLine($"Name: {response.Result.Name}"); -Console.WriteLine($"Age: {response.Result.Age}"); -Console.WriteLine($"Occupation: {response.Result.Occupation}"); +// Demonstrates how to work with structured output when streaming using the RunStreamingAsync method. +await UseStructuredOutputWithRunStreamingAsync(chatClient); -// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce. -ChatClientAgent agentWithPersonInfo = chatClient.AsAIAgent(new ChatClientAgentOptions() +// Demonstrates how to add structured output support to agents that don't natively support it using the structured output middleware. +// This approach is useful when working with agents that don't support structured output natively, or agents using models +// that don't have the capability to produce structured output, allowing you to still leverage structured output features by transforming +// the text output from the agent into structured data using a chat client. +await UseStructuredOutputWithMiddlewareAsync(chatClient); + +static async Task UseStructuredOutputWithResponseFormatAsync(ChatClient chatClient) +{ + Console.WriteLine("=== Structured Output with ResponseFormat ==="); + + // Create the agent + AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions() + { + Name = "HelpfulAssistant", + ChatOptions = new() + { + Instructions = "You are a helpful assistant.", + // Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent. + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } + }); + + // Invoke the agent with some unstructured input to extract the structured information from. + AgentResponse response = await agent.RunAsync("Provide information about the capital of France."); + + // Access the structured output via the Text property of the agent response as JSON in scenarios when JSON as text is required + // and no object instance is needed (e.g., for logging, forwarding to another service, or storing in a database). + Console.WriteLine("Assistant Output (JSON):"); + Console.WriteLine(response.Text); + Console.WriteLine(); + + // Deserialize the JSON text to work with the structured object in scenarios when you need to access properties, + // perform operations, or pass the data to methods that require the typed object instance. + CityInfo cityInfo = JsonSerializer.Deserialize(response.Text)!; + + Console.WriteLine("Assistant Output (Deserialized):"); + Console.WriteLine($"Name: {cityInfo.Name}"); + Console.WriteLine(); +} + +static async Task UseStructuredOutputWithRunAsync(ChatClient chatClient) +{ + Console.WriteLine("=== Structured Output with RunAsync ==="); + + // Create the agent + AIAgent agent = chatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); + + // Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input. + AgentResponse response = await agent.RunAsync("Provide information about the capital of France."); + + // Access the structured output via the Result property of the agent response. + CityInfo cityInfo = response.Result; + + Console.WriteLine("Assistant Output:"); + Console.WriteLine($"Name: {cityInfo.Name}"); + Console.WriteLine(); +} + +static async Task UseStructuredOutputWithRunStreamingAsync(ChatClient chatClient) +{ + Console.WriteLine("=== Structured Output with RunStreamingAsync ==="); + + // Create the agent + AIAgent agent = chatClient.AsAIAgent(new ChatClientAgentOptions() + { + Name = "HelpfulAssistant", + ChatOptions = new() + { + Instructions = "You are a helpful assistant.", + // Specify CityInfo as the type parameter of ForJsonSchema to indicate the expected structured output from the agent. + ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() + } + }); + + // Invoke the agent with some unstructured input while streaming, to extract the structured information from. + IAsyncEnumerable updates = agent.RunStreamingAsync("Provide information about the capital of France."); + + // Assemble all the parts of the streamed output. + AgentResponse nonGenericResponse = await updates.ToAgentResponseAsync(); + + // Access the structured output by deserializing JSON in the Text property. + CityInfo cityInfo = JsonSerializer.Deserialize(nonGenericResponse.Text)!; + + Console.WriteLine("Assistant Output:"); + Console.WriteLine($"Name: {cityInfo.Name}"); + Console.WriteLine(); +} + +static async Task UseStructuredOutputWithMiddlewareAsync(ChatClient chatClient) { - Name = "HelpfulAssistant", - ChatOptions = new() { Instructions = "You are a helpful assistant.", ResponseFormat = Microsoft.Extensions.AI.ChatResponseFormat.ForJsonSchema() } -}); + Console.WriteLine("=== Structured Output with UseStructuredOutput Middleware ==="); + + // Create chat client that will transform the agent text response into structured output. + IChatClient meaiChatClient = chatClient.AsIChatClient(); -// Invoke the agent with some unstructured input while streaming, to extract the structured information from. -var updates = agentWithPersonInfo.RunStreamingAsync("Please provide information about John Smith, who is a 35-year-old software engineer."); + // Create the agent + AIAgent agent = meaiChatClient.AsAIAgent(name: "HelpfulAssistant", instructions: "You are a helpful assistant."); -// Assemble all the parts of the streamed output, since we can only deserialize once we have the full json, -// then deserialize the response into the PersonInfo class. -PersonInfo personInfo = (await updates.ToAgentResponseAsync()).Deserialize(JsonSerializerOptions.Web); + // Add structured output middleware via UseStructuredOutput method to add structured output support to the agent. + // This middleware transforms the agent's text response into structured data using a chat client. + // Since our agent does support structured output natively, we will add a middleware that removes ResponseFormat + // from the AgentRunOptions to emulate an agent that doesn't support structured output natively + agent = agent + .AsBuilder() + .UseStructuredOutput(meaiChatClient) + .Use(ResponseFormatRemovalMiddleware, null) + .Build(); -Console.WriteLine("Assistant Output:"); -Console.WriteLine($"Name: {personInfo.Name}"); -Console.WriteLine($"Age: {personInfo.Age}"); -Console.WriteLine($"Occupation: {personInfo.Occupation}"); + // Set CityInfo as the type parameter of RunAsync method to specify the expected structured output from the agent and invoke it with some unstructured input. + AgentResponse response = await agent.RunAsync("Provide information about the capital of France."); + + // Access the structured output via the Result property of the agent response. + CityInfo cityInfo = response.Result; + + Console.WriteLine("Assistant Output:"); + Console.WriteLine($"Name: {cityInfo.Name}"); + Console.WriteLine(); +} + +static Task ResponseFormatRemovalMiddleware(IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent innerAgent, CancellationToken cancellationToken) +{ + // Remove any ResponseFormat from the options to emulate an agent that doesn't support structured output natively. + options = options?.Clone(); + options?.ResponseFormat = null; + + return innerAgent.RunAsync(messages, session, options, cancellationToken); +} namespace SampleApp { /// - /// Represents information about a person, including their name, age, and occupation, matched to the JSON schema used in the agent. + /// Represents information about a city, including its name. /// - [Description("Information about a person including their name, age, and occupation")] - public class PersonInfo + [Description("Information about a city")] + public sealed class CityInfo { [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/Agents/Agent_Step05_StructuredOutput/README.md b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/README.md new file mode 100644 index 0000000000..2471e92194 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/README.md @@ -0,0 +1,52 @@ +# Structured Output with ChatClientAgent + +This sample demonstrates how to configure ChatClientAgent to produce structured output in JSON format using various approaches. + +## What this sample demonstrates + +- **ResponseFormat approach**: Configuring agents with JSON schema response format via `ChatResponseFormat.ForJsonSchema()` for inter-agent communication or when the type is not known at compile time +- **Generic RunAsync method**: Using the generic `RunAsync` method for structured output when the caller needs to work directly with typed objects +- **Structured output with Streaming**: Using `RunStreamingAsync` to stream responses while still obtaining structured output by assembling and deserializing the streamed content +- **StructuredOutput middleware**: Adding structured output support to agents that don't natively support it (like A2A agents or models without structured output capability) by transforming text output into structured data using a chat client + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10 SDK or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource + +**Note**: This sample uses Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai). + +**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). + +## Environment Variables + +Set the following environment variables: + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +Navigate to the sample directory and run: + +```powershell +cd dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput +dotnet run +``` + +## Expected behavior + +The sample will demonstrate four different approaches to structured output: + +1. **Structured Output with ResponseFormat**: Creates an agent with `ResponseFormat` set to `ForJsonSchema()`, invokes it with unstructured input, and accesses the structured output via the `Text` property +2. **Structured Output with RunAsync**: Creates an agent and uses the generic `RunAsync()` method to get a typed `AgentResponse` with the result accessible via the `Result` property +3. **Structured Output with RunStreamingAsync**: Creates an agent with JSON schema response format, streams the response using `RunStreamingAsync`, assembles the updates using `ToAgentResponseAsync()`, and deserializes the JSON text into a typed object +4. **Structured Output with StructuredOutput Middleware**: Uses the `UseStructuredOutput` method on `AIAgentBuilder` to add structured output support to agents that don't natively support it + +Each approach will output information about the capital of France (Paris) in a structured format. diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgent.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgent.cs new file mode 100644 index 0000000000..641e0adfc4 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgent.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// A delegating AI agent that converts text responses from an inner AI agent into structured output using a chat client. +/// +/// +/// +/// The wraps an inner agent and uses a chat client to transform +/// the inner agent's text response into a structured JSON format based on the specified response format. +/// +/// +/// This agent requires a to be specified either through the +/// or the +/// provided during construction. +/// +/// +internal sealed class StructuredOutputAgent : DelegatingAIAgent +{ + private readonly IChatClient _chatClient; + private readonly StructuredOutputAgentOptions? _agentOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent that generates text responses to be converted to structured output. + /// The chat client used to transform text responses into structured JSON format. + /// Optional configuration options for the structured output agent. + public StructuredOutputAgent(AIAgent innerAgent, IChatClient chatClient, StructuredOutputAgentOptions? options = null) + : base(innerAgent) + { + this._chatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient)); + this._agentOptions = options; + } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + // Run the inner agent first, to get back the text response we want to convert. + var textResponse = await this.InnerAgent.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); + + // Invoke the chat client to transform the text output into structured data. + ChatResponse soResponse = await this._chatClient.GetResponseAsync( + messages: this.GetChatMessages(textResponse.Text), + options: this.GetChatOptions(options), + cancellationToken: cancellationToken).ConfigureAwait(false); + + return new StructuredOutputAgentResponse(soResponse, textResponse); + } + + private List GetChatMessages(string? textResponseText) + { + List chatMessages = []; + + if (this._agentOptions?.ChatClientSystemMessage is not null) + { + chatMessages.Add(new ChatMessage(ChatRole.System, this._agentOptions.ChatClientSystemMessage)); + } + + chatMessages.Add(new ChatMessage(ChatRole.User, textResponseText)); + + return chatMessages; + } + + private ChatOptions GetChatOptions(AgentRunOptions? options) + { + ChatResponseFormat responseFormat = options?.ResponseFormat + ?? this._agentOptions?.ChatOptions?.ResponseFormat + ?? throw new InvalidOperationException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but none was specified."); + + if (responseFormat is not ChatResponseFormatJson jsonResponseFormat) + { + throw new NotSupportedException($"A response format of type '{nameof(ChatResponseFormatJson)}' must be specified, but was '{responseFormat.GetType().Name}'."); + } + + var chatOptions = this._agentOptions?.ChatOptions?.Clone() ?? new ChatOptions(); + chatOptions.ResponseFormat = jsonResponseFormat; + return chatOptions; + } +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentOptions.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentOptions.cs new file mode 100644 index 0000000000..c5613d2015 --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentOptions.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// Represents configuration options for a . +/// +#pragma warning disable CA1812 // Instantiated via AIAgentBuilderExtensions.UseStructuredOutput optionsFactory parameter +internal sealed class StructuredOutputAgentOptions +#pragma warning restore CA1812 +{ + /// + /// Gets or sets the system message to use when invoking the chat client for structured output conversion. + /// + public string? ChatClientSystemMessage { get; set; } + + /// + /// Gets or sets the chat options to use for the structured output conversion by the chat client + /// used by the agent. + /// + /// + /// This property is optional. The should be set to a + /// instance to specify the expected JSON schema for the structured output. + /// Note that if is provided when running the agent, + /// it will take precedence and override the specified here. + /// + public ChatOptions? ChatOptions { get; set; } +} diff --git a/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentResponse.cs b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentResponse.cs new file mode 100644 index 0000000000..c903b9f3ca --- /dev/null +++ b/dotnet/samples/GettingStarted/Agents/Agent_Step05_StructuredOutput/StructuredOutputAgentResponse.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace SampleApp; + +/// +/// Represents an agent response that contains structured output and +/// the original agent response from which the structured output was generated. +/// +internal sealed class StructuredOutputAgentResponse : AgentResponse +{ + /// + /// Initializes a new instance of the class. + /// + /// The containing the structured output. + /// The original from the inner agent. + public StructuredOutputAgentResponse(ChatResponse chatResponse, AgentResponse agentResponse) : base(chatResponse) + { + this.OriginalResponse = agentResponse; + } + + /// + /// Gets the original non-structured response from the inner agent used by chat client to produce the structured output. + /// + public AgentResponse OriginalResponse { get; } +} diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs index fc26d09ea7..9d031bc64b 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step05_StructuredOutput/Program.cs @@ -64,7 +64,8 @@ // Assemble all the parts of the streamed output, since we can only deserialize once we have the full json, // then deserialize the response into the PersonInfo class. -PersonInfo personInfo = (await updates.ToAgentResponseAsync()).Deserialize(JsonSerializerOptions.Web); +PersonInfo personInfo = JsonSerializer.Deserialize((await updates.ToAgentResponseAsync()).Text, JsonSerializerOptions.Web) + ?? throw new InvalidOperationException("Failed to deserialize the streamed response into PersonInfo."); Console.WriteLine("Assistant Output:"); Console.WriteLine($"Name: {personInfo.Name}"); diff --git a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs index 38bb80dddc..de20cf31ad 100644 --- a/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs +++ b/dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs @@ -330,7 +330,8 @@ public override async ValueTask HandleAsync( // Convert the stream to a response and deserialize the structured output AgentResponse response = await updates.ToAgentResponseAsync(cancellationToken); - CriticDecision decision = response.Deserialize(JsonSerializerOptions.Web); + CriticDecision decision = JsonSerializer.Deserialize(response.Text, JsonSerializerOptions.Web) + ?? throw new JsonException("Failed to deserialize CriticDecision from response text."); Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}"); if (!string.IsNullOrEmpty(decision.Feedback)) diff --git a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs index 5968e4eb28..11caea6939 100644 --- a/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs +++ b/dotnet/samples/M365Agent/Agents/WeatherForecastAgent.cs @@ -54,7 +54,7 @@ protected override async Task RunCoreAsync(IEnumerable(JsonSerializerOptions.Web, out var structuredOutput)) + if (TryDeserialize(response.Text, JsonSerializerOptions.Web, out var structuredOutput)) { var textContentMessage = response.Messages.FirstOrDefault(x => x.Contents.OfType().Any()); if (textContentMessage is not null) @@ -112,4 +112,25 @@ private static AdaptiveCard CreateWeatherCard(string? location, string? conditio }); return card; } + + private static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) + { + try + { + T? result = JsonSerializer.Deserialize(json, jsonSerializerOptions); + if (result is null) + { + structuredOutput = default!; + return false; + } + + structuredOutput = result; + return true; + } + catch + { + structuredOutput = default!; + return false; + } + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs index 6258937cd2..6ebdfa7978 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgent.cs @@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI; /// may involve multiple agents working together. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public abstract class AIAgent +public abstract partial class AIAgent { private static readonly AsyncLocal s_currentContext = new(); diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs similarity index 58% rename from dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs rename to dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs index d933939884..f93b43157c 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentStructuredOutput.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AIAgentStructuredOutput.cs @@ -11,155 +11,130 @@ namespace Microsoft.Agents.AI; /// -/// Provides an that delegates to an implementation. +/// Provides structured output methods for that enable requesting responses in a specific type format. /// -public sealed partial class ChatClientAgent +public abstract partial class AIAgent { /// /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type . /// + /// The type of structured output to request. /// /// The conversation session to use for this invocation. If , a new session will be created. /// The session will be updated with any response messages generated during invocation. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// This overload is useful when the agent has sufficient context from previous messages in the session /// or from its initial configuration to generate a meaningful response without additional input. /// - public Task> RunAsync( + public Task> RunAsync( AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync([], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync([], session, serializerOptions, options, cancellationToken); /// /// Runs the agent with a text message from the user, requesting a response of the specified type . /// + /// The type of structured output to request. /// The user message to send to the agent. /// /// The conversation session to use for this invocation. If , a new session will be created. /// The session will be updated with the input message and any response messages generated during invocation. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is , empty, or contains only whitespace. /// /// The provided text will be wrapped in a with the role /// before being sent to the agent. This is a convenience method for simple text-based interactions. /// - public Task> RunAsync( + public Task> RunAsync( string message, AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) { _ = Throw.IfNullOrWhitespace(message); - return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); + return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); } /// /// Runs the agent with a single chat message, requesting a response of the specified type . /// + /// The type of structured output to request. /// The chat message to send to the agent. /// /// The conversation session to use for this invocation. If , a new session will be created. /// The session will be updated with the input message and any response messages generated during invocation. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// is . - public Task> RunAsync( + public Task> RunAsync( ChatMessage message, AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) { _ = Throw.IfNull(message); - return this.RunAsync([message], session, serializerOptions, options, useJsonSchemaResponseFormat, cancellationToken); + return this.RunAsync([message], session, serializerOptions, options, cancellationToken); } /// /// Runs the agent with a collection of chat messages, requesting a response of the specified type . /// + /// The type of structured output to request. /// The collection of messages to send to the agent for processing. /// /// The conversation session to use for this invocation. If , a new session will be created. /// The session will be updated with the input messages and any response messages generated during invocation. /// - /// The JSON serialization options to use. + /// Optional JSON serializer options to use for deserializing the response. /// Optional configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - /// The type of structured output to request. + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. /// /// - /// This is the primary invocation method that implementations must override. It handles collections of messages, - /// allowing for complex conversational scenarios including multi-turn interactions, function calls, and - /// context-rich conversations. + /// This method handles collections of messages, allowing for complex conversational scenarios including + /// multi-turn interactions, function calls, and context-rich conversations. /// /// /// The messages are processed in the order provided and become part of the conversation history. /// The agent's response will also be added to if one is provided. /// /// - public Task> RunAsync( + public async Task> RunAsync( IEnumerable messages, AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) { - async Task> GetResponseAsync(IChatClient chatClient, List threadMessages, ChatOptions? chatOptions, CancellationToken ct) - { - return await chatClient.GetResponseAsync( - threadMessages, - serializerOptions ?? AgentJsonUtilities.DefaultOptions, - chatOptions, - useJsonSchemaResponseFormat, - ct).ConfigureAwait(false); - } + serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; + + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); + + (responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat); + + options = options?.Clone() ?? new AgentRunOptions(); + options.ResponseFormat = responseFormat; - static ChatClientAgentResponse CreateResponse(ChatResponse chatResponse) - { - return new ChatClientAgentResponse(chatResponse) - { - ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken) - }; - } + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken).ConfigureAwait(false); - return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, session, options, cancellationToken); + return new AgentResponse(response, serializerOptions) { IsWrappedInObject = isWrappedInObject }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs index c93c8e9184..168f607491 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse.cs @@ -1,20 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. using System; -#if NET -using System.Buffers; -#endif using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; - -#if NET -using System.Text; -#endif -using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using Microsoft.Shared.Diagnostics; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -76,6 +67,29 @@ public AgentResponse(ChatResponse response) this.ContinuationToken = response.ContinuationToken; } + /// + /// Initializes a new instance of the class from an existing . + /// + /// The from which to copy properties. + /// is . + /// + /// This constructor creates a copy of an existing agent response, preserving all + /// metadata and storing the original response in for access to + /// the underlying implementation details. + /// + protected AgentResponse(AgentResponse response) + { + _ = Throw.IfNull(response); + + this.AdditionalProperties = response.AdditionalProperties; + this.CreatedAt = response.CreatedAt; + this.Messages = response.Messages; + this.RawRepresentation = response; + this.ResponseId = response.ResponseId; + this.Usage = response.Usage; + this.ContinuationToken = response.ContinuationToken; + } + /// /// Initializes a new instance of the class with the specified collection of messages. /// @@ -274,117 +288,4 @@ public AgentResponseUpdate[] ToAgentResponseUpdates() return updates; } - - /// - /// Deserializes the response text into the given type. - /// - /// The output type to deserialize into. - /// The result as the requested type. - /// The result is not parsable into the requested type. - public T Deserialize() => - this.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions); - - /// - /// Deserializes the response text into the given type using the specified serializer options. - /// - /// The output type to deserialize into. - /// The JSON serialization options to use. - /// The result as the requested type. - /// The result is not parsable into the requested type. - public T Deserialize(JsonSerializerOptions serializerOptions) - { - _ = Throw.IfNull(serializerOptions); - - var structuredOutput = this.GetResultCore(serializerOptions, out var failureReason); - return failureReason switch - { - FailureReason.ResultDidNotContainJson => throw new InvalidOperationException("The response did not contain JSON to be deserialized."), - FailureReason.DeserializationProducedNull => throw new InvalidOperationException("The deserialized response is null."), - _ => structuredOutput!, - }; - } - - /// - /// Tries to deserialize response text into the given type. - /// - /// The output type to deserialize into. - /// The parsed structured output. - /// if parsing was successful; otherwise, . - public bool TryDeserialize([NotNullWhen(true)] out T? structuredOutput) => - this.TryDeserialize(AgentAbstractionsJsonUtilities.DefaultOptions, out structuredOutput); - - /// - /// Tries to deserialize response text into the given type using the specified serializer options. - /// - /// The output type to deserialize into. - /// The JSON serialization options to use. - /// The parsed structured output. - /// if parsing was successful; otherwise, . - public bool TryDeserialize(JsonSerializerOptions serializerOptions, [NotNullWhen(true)] out T? structuredOutput) - { - _ = Throw.IfNull(serializerOptions); - - try - { - structuredOutput = this.GetResultCore(serializerOptions, out var failureReason); - return failureReason is null; - } - catch - { - structuredOutput = default; - return false; - } - } - - private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) - { -#if NET - // We need to deserialize only the first top-level object as a workaround for a common LLM backend - // issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call. - // See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348 - var utf8ByteLength = Encoding.UTF8.GetByteCount(json); - var buffer = ArrayPool.Shared.Rent(utf8ByteLength); - try - { - var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); - var reader = new Utf8JsonReader(new ReadOnlySpan(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true }); - return JsonSerializer.Deserialize(ref reader, typeInfo); - } - finally - { - ArrayPool.Shared.Return(buffer); - } -#else - return JsonSerializer.Deserialize(json, typeInfo); -#endif - } - - private T? GetResultCore(JsonSerializerOptions serializerOptions, out FailureReason? failureReason) - { - var json = this.Text; - if (string.IsNullOrEmpty(json)) - { - failureReason = FailureReason.ResultDidNotContainJson; - return default; - } - - // If there's an exception here, we want it to propagate, since the Result property is meant to throw directly - - T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T))); - - if (deserialized is null) - { - failureReason = FailureReason.DeserializationProducedNull; - return default; - } - - failureReason = default; - return deserialized; - } - - private enum FailureReason - { - ResultDidNotContainJson, - DeserializationProducedNull - } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs index 2a18aadb37..7f12aaed5f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentResponse{T}.cs @@ -1,6 +1,17 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.AI; +using System; +#if NET +using System.Buffers; +#endif + +#if NET +using System.Text; +#endif +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI; @@ -8,23 +19,80 @@ namespace Microsoft.Agents.AI; /// Represents the response of the specified type to an run request. /// /// The type of value expected from the agent. -public abstract class AgentResponse : AgentResponse +public class AgentResponse : AgentResponse { - /// Initializes a new instance of the class. - protected AgentResponse() - { - } + private readonly JsonSerializerOptions _serializerOptions; /// - /// Initializes a new instance of the class from an existing . + /// Initializes a new instance of the class. /// - /// The from which to populate this . - protected AgentResponse(ChatResponse response) : base(response) + /// The from which to populate this . + /// The to use when deserializing the result. + /// is . + public AgentResponse(AgentResponse response, JsonSerializerOptions serializerOptions) : base(response) { + _ = Throw.IfNull(serializerOptions); + + this._serializerOptions = serializerOptions; } + /// + /// Gets or sets a value indicating whether the JSON schema has an extra object wrapper. + /// + /// + /// The wrapper is required for any non-JSON-object-typed values such as numbers, enum values, and arrays. + /// + public bool IsWrappedInObject { get; init; } + /// /// Gets the result value of the agent response as an instance of . /// - public abstract T Result { get; } + [JsonIgnore] + public virtual T Result + { + get + { + var json = this.Text; + if (string.IsNullOrEmpty(json)) + { + throw new InvalidOperationException("The response did not contain JSON to be deserialized."); + } + + if (this.IsWrappedInObject) + { + json = StructuredOutputSchemaUtilities.UnwrapResponseData(json!); + } + + T? deserialized = DeserializeFirstTopLevelObject(json!, (JsonTypeInfo)this._serializerOptions.GetTypeInfo(typeof(T))); + if (deserialized is null) + { + throw new InvalidOperationException("The deserialized response is null."); + } + + return deserialized; + } + } + + private static T? DeserializeFirstTopLevelObject(string json, JsonTypeInfo typeInfo) + { +#if NET + // We need to deserialize only the first top-level object as a workaround for a common LLM backend + // issue. GPT 3.5 Turbo commonly returns multiple top-level objects after doing a function call. + // See https://community.openai.com/t/2-json-objects-returned-when-using-function-calling-and-json-mode/574348 + var utf8ByteLength = Encoding.UTF8.GetByteCount(json); + var buffer = ArrayPool.Shared.Rent(utf8ByteLength); + try + { + var utf8SpanLength = Encoding.UTF8.GetBytes(json, 0, json.Length, buffer, 0); + var reader = new Utf8JsonReader(new ReadOnlySpan(buffer, 0, utf8SpanLength), new() { AllowMultipleValues = true }); + return JsonSerializer.Deserialize(ref reader, typeInfo); + } + finally + { + ArrayPool.Shared.Return(buffer); + } +#else + return JsonSerializer.Deserialize(json, typeInfo); +#endif + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs index 611d15036c..08811df288 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentRunOptions.cs @@ -28,12 +28,13 @@ public AgentRunOptions() /// /// The options instance from which to copy values. /// is . - public AgentRunOptions(AgentRunOptions options) + protected AgentRunOptions(AgentRunOptions options) { _ = Throw.IfNull(options); this.ContinuationToken = options.ContinuationToken; this.AllowBackgroundResponses = options.AllowBackgroundResponses; this.AdditionalProperties = options.AdditionalProperties?.Clone(); + this.ResponseFormat = options.ResponseFormat; } /// @@ -90,4 +91,35 @@ public AgentRunOptions(AgentRunOptions options) /// preserving implementation-specific details or extending the options with custom data. /// public AdditionalPropertiesDictionary? AdditionalProperties { get; set; } + + /// + /// Gets or sets the response format. + /// + /// + /// If , no response format is specified and the agent will use its default. + /// This property can be set to to specify that the response should be unstructured text, + /// to to specify that the response should be structured JSON data, or + /// an instance of constructed with a specific JSON schema to request that the + /// response be structured JSON data according to that schema. It is up to the agent implementation if or how + /// to honor the request. If the agent implementation doesn't recognize the specific kind of , + /// it can be ignored. + /// + public ChatResponseFormat? ResponseFormat { get; set; } + + /// + /// Produces a clone of the current instance. + /// + /// + /// A clone of the current instance. + /// + /// + /// + /// The clone will have the same values for all properties as the original instance. Any collections, like , + /// are shallow-cloned, meaning a new collection instance is created, but any references contained by the collections are shared with the original. + /// + /// + /// Derived types should override to return an instance of the derived type. + /// + /// + public virtual AgentRunOptions Clone() => new(this); } diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj index 6b6f9d44f2..0f7ae06530 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj @@ -8,6 +8,7 @@ true + true true true true diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index ff886e2ebe..b0124d3de7 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -13,6 +13,7 @@ - Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699)) - Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879)) - Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806)) +- Marked all `RunAsync` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803)) ## v1.0.0-preview.251204.1 diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs index c790222e50..ee0c457deb 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgent.cs @@ -1,12 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using Microsoft.DurableTask; using Microsoft.DurableTask.Entities; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.DurableTask; @@ -114,7 +113,6 @@ protected override async Task RunCoreAsync( { enableToolCalls = durableOptions.EnableToolCalls; enableToolNames = durableOptions.EnableToolNames; - responseFormat = durableOptions.ResponseFormat; } else if (options is ChatClientAgentRunOptions chatClientOptions && chatClientOptions.ChatOptions?.Tools != null) { @@ -122,6 +120,12 @@ protected override async Task RunCoreAsync( responseFormat = chatClientOptions.ChatOptions?.ResponseFormat; } + // Override the response format if specified in the agent run options + if (options?.ResponseFormat is { } format) + { + responseFormat = format; + } + RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames) { OrchestrationId = this._context.InstanceId @@ -168,108 +172,125 @@ protected override async IAsyncEnumerable RunCoreStreamingA } /// - /// Runs the agent with a message and returns the deserialized output as an instance of . + /// Run the agent with no message assuming that all required instructions are already provided to the agent or on the session, and requesting a response of the specified type . /// - /// The message to send to the agent. - /// The agent session to use. - /// Optional JSON serializer options. - /// Optional run options. - /// The cancellation token. - /// The type of the output. - /// - /// Thrown when the provided already contains a response schema. - /// Thrown when the provided is not a . - /// - /// - /// Thrown when the agent response is empty or cannot be deserialized. - /// - /// The output from the agent. - public async Task> RunAsync( + /// The type of structured output to request. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// This method is specific to durable agents because the Durable Task Framework uses a custom + /// synchronization context for orchestration execution, and all continuations must run on the + /// orchestration thread to avoid breaking the durable orchestration and potential deadlocks. + /// + public new Task> RunAsync( + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => + this.RunAsync([], session, serializerOptions, options, cancellationToken); + + /// + /// Runs the agent with a text message from the user, requesting a response of the specified type . + /// + /// The type of structured output to request. + /// The user message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is , empty, or contains only whitespace. + /// + /// + /// + public new Task> RunAsync( string message, AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - return await this.RunAsync( - messages: [new ChatMessage(ChatRole.User, message) { CreatedAt = DateTimeOffset.UtcNow }], - session, - serializerOptions, - options, - cancellationToken); + _ = Throw.IfNull(message); + + return this.RunAsync(new ChatMessage(ChatRole.User, message), session, serializerOptions, options, cancellationToken); } /// - /// Runs the agent with messages and returns the deserialized output as an instance of . + /// Runs the agent with a single chat message, requesting a response of the specified type . /// - /// The messages to send to the agent. - /// The agent session to use. - /// Optional JSON serializer options. - /// Optional run options. - /// The cancellation token. - /// The type of the output. - /// - /// Thrown when the provided already contains a response schema. - /// Thrown when the provided is not a . - /// - /// - /// Thrown when the agent response is empty or cannot be deserialized. - /// - /// The output from the agent. - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")] - [UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback to reflection-based deserialization is intentional for library flexibility with user-defined types.")] - public async Task> RunAsync( - IEnumerable messages, + /// The type of structured output to request. + /// The chat message to send to the agent. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input message and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// is . + /// + /// + /// + public new Task> RunAsync( + ChatMessage message, AgentSession? session = null, JsonSerializerOptions? serializerOptions = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) { - options ??= new DurableAgentRunOptions(); - if (options is not DurableAgentRunOptions durableOptions) - { - throw new ArgumentException( - "Response schema is only supported with DurableAgentRunOptions when using durable agents. " + - "Cannot specify a response schema when calling RunAsync.", - paramName: nameof(options)); - } - - if (durableOptions.ResponseFormat is not null) - { - throw new ArgumentException( - "A response schema is already defined in the provided DurableAgentRunOptions. " + - "Cannot specify a response schema when calling RunAsync.", - paramName: nameof(options)); - } + _ = Throw.IfNull(message); - // Create the JSON schema for the response type - durableOptions.ResponseFormat = ChatResponseFormat.ForJsonSchema(); + return this.RunAsync([message], session, serializerOptions, options, cancellationToken); + } - AgentResponse response = await this.RunAsync(messages, session, durableOptions, cancellationToken); + /// + /// Runs the agent with a collection of chat messages, requesting a response of the specified type . + /// + /// The type of structured output to request. + /// The collection of messages to send to the agent for processing. + /// + /// The conversation session to use for this invocation. If , a new session will be created. + /// The session will be updated with the input messages and any response messages generated during invocation. + /// + /// Optional JSON serializer options to use for deserializing the response. + /// Optional configuration parameters for controlling the agent's invocation behavior. + /// The to monitor for cancellation requests. The default is . + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + /// + /// + /// + public new async Task> RunAsync( + IEnumerable messages, + AgentSession? session = null, + JsonSerializerOptions? serializerOptions = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + serializerOptions ??= AgentAbstractionsJsonUtilities.DefaultOptions; - // Deserialize the response text to the requested type - if (string.IsNullOrEmpty(response.Text)) - { - throw new InvalidOperationException("Agent response is empty and cannot be deserialized."); - } + var responseFormat = ChatResponseFormat.ForJsonSchema(serializerOptions); - serializerOptions ??= DurableAgentJsonUtilities.DefaultOptions; + (responseFormat, bool isWrappedInObject) = StructuredOutputSchemaUtilities.WrapNonObjectSchema(responseFormat); - // Prefer source-generated metadata when available to support AOT/trimming scenarios. - // Fallback to reflection-based deserialization for types without source-generated metadata. - // This is necessary since T is a user-provided type that may not have [JsonSerializable] coverage. - JsonTypeInfo? typeInfo = serializerOptions.GetTypeInfo(typeof(T)); - T? result = (typeInfo is JsonTypeInfo typedInfo - ? (T?)JsonSerializer.Deserialize(response.Text, typedInfo) - : JsonSerializer.Deserialize(response.Text, serializerOptions)) - ?? throw new InvalidOperationException($"Failed to deserialize agent response to type {typeof(T).Name}."); + options = options?.Clone() ?? new DurableAgentRunOptions(); + options.ResponseFormat = responseFormat; - return new DurableAIAgentResponse(response, result); - } + // ConfigureAwait(false) cannot be used here because the Durable Task Framework uses + // a custom synchronization context that requires all continuations to execute on the + // orchestration thread. Scheduling the continuation on an arbitrary thread would break + // the orchestration. + AgentResponse response = await this.RunAsync(messages, session, options, cancellationToken); - private sealed class DurableAIAgentResponse(AgentResponse response, T result) - : AgentResponse(response.AsChatResponse()) - { - public override T Result { get; } = result; + return new AgentResponse(response, serializerOptions) { IsWrappedInObject = isWrappedInObject }; } } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs index f0f7a4ffd4..8987343c60 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAIAgentProxy.cs @@ -62,7 +62,6 @@ protected override async Task RunCoreAsync( { enableToolCalls = durableOptions.EnableToolCalls; enableToolNames = durableOptions.EnableToolNames; - responseFormat = durableOptions.ResponseFormat; isFireAndForget = durableOptions.IsFireAndForget; } else if (options is ChatClientAgentRunOptions chatClientOptions) @@ -71,6 +70,12 @@ protected override async Task RunCoreAsync( responseFormat = chatClientOptions.ChatOptions?.ResponseFormat; } + // Override the response format if specified in the agent run options + if (options?.ResponseFormat is { } format) + { + responseFormat = format; + } + RunRequest request = new([.. messages], responseFormat, enableToolCalls, enableToolNames); AgentSessionId sessionId = durableSession.SessionId; diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs index 0f1984ad62..f698eab3d8 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/DurableAgentRunOptions.cs @@ -1,7 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using Microsoft.Extensions.AI; - namespace Microsoft.Agents.AI.DurableTask; /// @@ -9,6 +7,25 @@ namespace Microsoft.Agents.AI.DurableTask; /// public sealed class DurableAgentRunOptions : AgentRunOptions { + /// + /// Initializes a new instance of the class. + /// + public DurableAgentRunOptions() + { + } + + /// + /// Initializes a new instance of the class by copying values from the specified options. + /// + /// The options instance from which to copy values. + private DurableAgentRunOptions(DurableAgentRunOptions options) + : base(options) + { + this.EnableToolCalls = options.EnableToolCalls; + this.EnableToolNames = options.EnableToolNames is not null ? new List(options.EnableToolNames) : null; + this.IsFireAndForget = options.IsFireAndForget; + } + /// /// Gets or sets whether to enable tool calls for this request. /// @@ -19,11 +36,6 @@ public sealed class DurableAgentRunOptions : AgentRunOptions /// public IList? EnableToolNames { get; set; } - /// - /// Gets or sets the response format for the agent's response. - /// - public ChatResponseFormat? ResponseFormat { get; set; } - /// /// Gets or sets whether to fire and forget the agent run request. /// @@ -33,4 +45,7 @@ public sealed class DurableAgentRunOptions : AgentRunOptions /// long-running tasks where the caller does not need to wait for the agent to complete the run. /// public bool IsFireAndForget { get; set; } + + /// + public override AgentRunOptions Clone() => new DurableAgentRunOptions(this); } diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj index 43ebe9c61f..8233afe964 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj @@ -17,6 +17,11 @@ README.md + + true + true + + diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs index 6dc6d175a7..acf90fc16d 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs @@ -612,6 +612,12 @@ private async Task NotifyAIContextProviderOfFailureAsync( chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses; } + if (agentRunOptions?.ResponseFormat is not null) + { + chatOptions ??= new ChatOptions(); + chatOptions.ResponseFormat = agentRunOptions.ResponseFormat; + } + ChatClientAgentContinuationToken? agentContinuationToken = null; if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken) diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs index 25ae5a903e..e5d6296700 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentCustomOptions.cs @@ -162,19 +162,14 @@ public IAsyncEnumerable RunStreamingAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a text message from the user, requesting a response of the specified type . @@ -186,20 +181,15 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( string message, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a single chat message, requesting a response of the specified type . @@ -211,20 +201,15 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( ChatMessage message, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(message, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); /// /// Runs the agent with a collection of chat messages, requesting a response of the specified type . @@ -236,18 +221,13 @@ public Task> RunAsync( /// /// The JSON serialization options to use. /// Configuration parameters for controlling the agent's invocation behavior. - /// - /// to set a JSON schema on the ; otherwise, . The default is . - /// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it. - /// /// The to monitor for cancellation requests. The default is . - /// A task that represents the asynchronous operation. The task result contains an with the agent's output. - public Task> RunAsync( + /// A task that represents the asynchronous operation. The task result contains an with the agent's output. + public Task> RunAsync( IEnumerable messages, AgentSession? session, JsonSerializerOptions? serializerOptions, ChatClientAgentRunOptions? options, - bool? useJsonSchemaResponseFormat = null, CancellationToken cancellationToken = default) => - this.RunAsync(messages, session, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken); + this.RunAsync(messages, session, serializerOptions, (AgentRunOptions?)options, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs index 0f2c9da485..cf35aa80a1 100644 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunOptions.cs @@ -26,6 +26,17 @@ public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) this.ChatOptions = chatOptions; } + /// + /// Initializes a new instance of the class by copying values from the specified options. + /// + /// The options instance from which to copy values. + private ChatClientAgentRunOptions(ChatClientAgentRunOptions options) + : base(options) + { + this.ChatOptions = options.ChatOptions?.Clone(); + this.ChatClientFactory = options.ChatClientFactory; + } + /// /// Gets or sets the chat options to apply to the agent invocation. /// @@ -50,4 +61,7 @@ public ChatClientAgentRunOptions(ChatOptions? chatOptions = null) /// chat client will be used without modification. /// public Func? ChatClientFactory { get; set; } + + /// + public override AgentRunOptions Clone() => new ChatClientAgentRunOptions(this); } diff --git a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs b/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs deleted file mode 100644 index a4fadff0c7..0000000000 --- a/dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentRunResponse{T}.cs +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI; - -/// -/// Represents the response of the specified type to an run request. -/// -/// The type of value expected from the chat response. -/// -/// Language models are not guaranteed to honor the requested schema. If the model's output is not -/// parsable as the expected type, you can access the underlying JSON response on the property. -/// -public sealed class ChatClientAgentResponse : AgentResponse -{ - private readonly ChatResponse _response; - - /// - /// Initializes a new instance of the class from an existing . - /// - /// The from which to populate this . - /// is . - /// - /// This constructor creates an agent response that wraps an existing , preserving all - /// metadata and storing the original response in for access to - /// the underlying implementation details. - /// - public ChatClientAgentResponse(ChatResponse response) : base(response) - { - _ = Throw.IfNull(response); - - this._response = response; - } - - /// - /// Gets the result value of the agent response as an instance of . - /// - /// - /// If the response did not contain JSON, or if deserialization fails, this property will throw. - /// - public override T Result => this._response.Result; -} diff --git a/dotnet/src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs b/dotnet/src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs new file mode 100644 index 0000000000..95836b95c4 --- /dev/null +++ b/dotnet/src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +#pragma warning disable IDE0005 // Using directive is unnecessary. + +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// Internal utilities for working with structured output JSON schemas. +/// +internal static class StructuredOutputSchemaUtilities +{ + private const string DataPropertyName = "data"; + + /// + /// Ensures the given response format has an object schema at the root, wrapping non-object schemas if necessary. + /// + /// The response format to check. + /// A tuple containing the (possibly wrapped) response format and whether wrapping occurred. + /// The response format does not have a valid JSON schema. + internal static (ChatResponseFormatJson ResponseFormat, bool IsWrappedInObject) WrapNonObjectSchema(ChatResponseFormatJson responseFormat) + { + if (responseFormat.Schema is null) + { + throw new InvalidOperationException("The response format must have a valid JSON schema."); + } + + var schema = responseFormat.Schema.Value; + bool isWrappedInObject = false; + + if (!SchemaRepresentsObject(responseFormat.Schema)) + { + // For non-object-representing schemas, we wrap them in an object schema, because all + // the real LLM providers today require an object schema as the root. This is currently + // true even for providers that support native structured output. + isWrappedInObject = true; + schema = JsonSerializer.SerializeToElement(new JsonObject + { + { "$schema", "https://json-schema.org/draft/2020-12/schema" }, + { "type", "object" }, + { "properties", new JsonObject { { DataPropertyName, JsonElementToJsonNode(schema) } } }, + { "additionalProperties", false }, + { "required", new JsonArray(DataPropertyName) }, + }, AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonObject))); + + responseFormat = ChatResponseFormat.ForJsonSchema(schema, responseFormat.SchemaName, responseFormat.SchemaDescription); + } + + return (responseFormat, isWrappedInObject); + } + + /// + /// Unwraps the "data" property from a JSON object that was previously wrapped by . + /// + /// The JSON string to unwrap. + /// The raw JSON text of the "data" property, or the original JSON if no wrapping is detected. + internal static string UnwrapResponseData(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind == JsonValueKind.Object && + document.RootElement.TryGetProperty(DataPropertyName, out JsonElement dataElement)) + { + return dataElement.GetRawText(); + } + + // If root is not an object or "data" property is not found, return the original JSON as a fallback + return json; + } + + private static bool SchemaRepresentsObject(JsonElement? schema) + { + if (schema is not { } schemaElement) + { + return false; + } + + if (schemaElement.ValueKind is JsonValueKind.Object) + { + foreach (var property in schemaElement.EnumerateObject()) + { + if (property.NameEquals("type"u8)) + { + return property.Value.ValueKind == JsonValueKind.String + && property.Value.ValueEquals("object"u8); + } + } + } + + return false; + } + + private static JsonNode? JsonElementToJsonNode(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.Array => JsonArray.Create(element), + JsonValueKind.Object => JsonObject.Create(element), + _ => JsonValue.Create(element) + }; +} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs new file mode 100644 index 0000000000..6b7556b456 --- /dev/null +++ b/dotnet/tests/AgentConformance.IntegrationTests/StructuredOutputRunTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AgentConformance.IntegrationTests; + +/// +/// Conformance tests for structured output handling for run methods on agents. +/// +/// The type of test fixture used by the concrete test implementation. +/// Function to create the test fixture with. +public abstract class StructuredOutputRunTests(Func createAgentFixture) : AgentTests(createAgentFixture) + where TAgentFixture : IAgentFixture +{ + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithResponseFormatReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + var options = new AgentRunOptions + { + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + }; + + // Act + var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session, options); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo)); + Assert.Equal("Paris", cityInfo.Name); + } + + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithGenericTypeReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "Provide information about the capital of France."), + session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + + Assert.NotNull(response.Result); + Assert.Equal("Paris", response.Result.Name); + } + + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public virtual async Task RunWithPrimitiveTypeReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act - Request a primitive type, which requires wrapping in an object schema + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "What is the sum of 15 and 27? Respond with just the number."), + session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Equal(42, response.Result); + } + + protected static bool TryDeserialize(string json, JsonSerializerOptions jsonSerializerOptions, out T structuredOutput) + { + try + { + T? deserialized = JsonSerializer.Deserialize(json, jsonSerializerOptions); + if (deserialized is null) + { + structuredOutput = default!; + return false; + } + + structuredOutput = deserialized; + return true; + } + catch + { + structuredOutput = default!; + return false; + } + } +} + +public sealed class CityInfo +{ + public string? Name { get; set; } +} diff --git a/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs b/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs index 178b1951ba..232b5fdb10 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/Support/Constants.cs @@ -2,7 +2,7 @@ namespace AgentConformance.IntegrationTests.Support; -internal static class Constants +public static class Constants { public const int RetryCount = 3; public const int RetryDelay = 5000; diff --git a/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs b/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs index c59b999fd2..91e858e53f 100644 --- a/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs +++ b/dotnet/tests/AgentConformance.IntegrationTests/Support/SessionCleanup.cs @@ -11,7 +11,7 @@ namespace AgentConformance.IntegrationTests.Support; /// /// The session to delete. /// The fixture that provides agent specific capabilities. -internal sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable +public sealed class SessionCleanup(AgentSession session, IAgentFixture fixture) : IAsyncDisposable { public async ValueTask DisposeAsync() => await fixture.DeleteSessionAsync(session); diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..94ce01e221 --- /dev/null +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientAgentStructuredOutputRunTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; +using AgentConformance.IntegrationTests.Support; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace AzureAI.IntegrationTests; + +public class AIProjectClientAgentStructuredOutputRunTests() : StructuredOutputRunTests>(() => new AIProjectClientStructuredOutputFixture()) +{ + private const string NotSupported = "AIProjectClient does not support specifying structured output type at invocation time."; + + /// + /// Verifies that response format provided at agent initialization is used when invoking RunAsync. + /// + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + var response = await agent.RunAsync(new ChatMessage(ChatRole.User, "Provide information about the capital of France."), session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + Assert.True(TryDeserialize(response.Text, AgentAbstractionsJsonUtilities.DefaultOptions, out CityInfo cityInfo)); + Assert.Equal("Paris", cityInfo.Name); + } + + /// + /// Verifies that generic RunAsync works with AIProjectClient when structured output is configured at agent initialization. + /// + /// + /// AIProjectClient does not support specifying the structured output type at invocation time yet. + /// The type T provided to RunAsync<T> is ignored by AzureAIProjectChatClient and is only used + /// for deserializing the agent response by AgentResponse<T>.Result. + /// + [RetryFact(Constants.RetryCount, Constants.RetryDelay)] + public async Task RunGenericWithResponseFormatAtAgentInitializationReturnsExpectedResultAsync() + { + // Arrange + var agent = this.Fixture.Agent; + var session = await agent.CreateSessionAsync(); + await using var cleanup = new SessionCleanup(session, this.Fixture); + + // Act + AgentResponse response = await agent.RunAsync( + new ChatMessage(ChatRole.User, "Provide information about the capital of France."), + session); + + // Assert + Assert.NotNull(response); + Assert.Single(response.Messages); + Assert.Contains("Paris", response.Text); + + Assert.NotNull(response.Result); + Assert.Equal("Paris", response.Result.Name); + } + + [Fact(Skip = NotSupported)] + public override Task RunWithGenericTypeReturnsExpectedResultAsync() => + base.RunWithGenericTypeReturnsExpectedResultAsync(); + + [Fact(Skip = NotSupported)] + public override Task RunWithResponseFormatReturnsExpectedResultAsync() => + base.RunWithResponseFormatReturnsExpectedResultAsync(); + + [Fact(Skip = NotSupported)] + public override Task RunWithPrimitiveTypeReturnsExpectedResultAsync() => + base.RunWithPrimitiveTypeReturnsExpectedResultAsync(); +} + +/// +/// Represents a fixture for testing AIProjectClient with structured output of type provided at agent initialization. +/// +public class AIProjectClientStructuredOutputFixture : AIProjectClientFixture +{ + public override Task InitializeAsync() + { + var agentOptions = new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = ChatResponseFormat.ForJsonSchema(AgentAbstractionsJsonUtilities.DefaultOptions) + }, + }; + + return this.InitializeAsync(agentOptions); + } +} diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index dd926174c0..a13af9c940 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -121,6 +121,13 @@ public async Task CreateChatClientAgentAsync( return await this._client.CreateAIAgentAsync(GenerateUniqueAgentName(name), model: s_config.DeploymentName, instructions: instructions, tools: aiTools); } + public async Task CreateChatClientAgentAsync(ChatClientAgentOptions options) + { + options.Name ??= GenerateUniqueAgentName("HelpfulAssistant"); + + return await this._client.CreateAIAgentAsync(model: s_config.DeploymentName, options); + } + public static string GenerateUniqueAgentName(string baseName) => $"{baseName}-{Guid.NewGuid().ToString("N").Substring(0, 8)}"; @@ -161,9 +168,15 @@ public Task DisposeAsync() return Task.CompletedTask; } - public async Task InitializeAsync() + public virtual async Task InitializeAsync() { this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential()); this._agent = await this.CreateChatClientAgentAsync(); } + + public async Task InitializeAsync(ChatClientAgentOptions options) + { + this._client = new(new Uri(s_config.Endpoint), new AzureCliCredential()); + this._agent = await this.CreateChatClientAgentAsync(options); + } } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs new file mode 100644 index 0000000000..3e28e025d6 --- /dev/null +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using AgentConformance.IntegrationTests; + +namespace AzureAIAgentsPersistent.IntegrationTests; + +public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests(() => new()) +{ + [Fact(Skip = "Fails intermittently, at build agent")] + public override Task RunWithResponseFormatReturnsExpectedResultAsync() => + base.RunWithResponseFormatReturnsExpectedResultAsync(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentStructuredOutputTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentStructuredOutputTests.cs new file mode 100644 index 0000000000..a8881ca761 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AIAgentStructuredOutputTests.cs @@ -0,0 +1,391 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Abstractions.UnitTests.Models; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for the structured output functionality in . +/// +public class AIAgentStructuredOutputTests +{ + private readonly Mock _agentMock; + + public AIAgentStructuredOutputTests() + { + this._agentMock = new Mock { CallBase = true }; + } + + #region Schema Wrapping Tests + + /// + /// Verifies that when requesting an object type, the schema is NOT wrapped. + /// + [Fact] + public async Task RunAsyncGeneric_WithObjectType_DoesNotWrapSchemaAsync() + { + // Arrange + Animal expectedAnimal = new() { Id = 1, FullName = "Test", Species = Species.Tiger }; + string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal); + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Get me an animal", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert - Verify the result is NOT marked as wrapped + Assert.False(result.IsWrappedInObject); + } + + /// + /// Verifies that when requesting a primitive type (int), the schema IS wrapped. + /// + [Fact] + public async Task RunAsyncGeneric_WithPrimitiveType_WrapsSchemaAsync() + { + // Arrange + const string ResponseJson = "{\"data\":42}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me a number", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert - Verify the result is marked as wrapped + Assert.True(result.IsWrappedInObject); + } + + /// + /// Verifies that when requesting an array type, the schema IS wrapped. + /// + [Fact] + public async Task RunAsyncGeneric_WithArrayType_WrapsSchemaAsync() + { + // Arrange + const string ResponseJson = "{\"data\":[\"a\",\"b\",\"c\"]}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me an array of strings", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert - Verify the result is marked as wrapped + Assert.True(result.IsWrappedInObject); + } + + /// + /// Verifies that when requesting an enum type, the schema IS wrapped. + /// + [Fact] + public async Task RunAsyncGeneric_WithEnumType_WrapsSchemaAsync() + { + // Arrange + const string ResponseJson = "{\"data\":\"Tiger\"}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me a species", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert - Verify the result is marked as wrapped + Assert.True(result.IsWrappedInObject); + } + + #endregion + + #region AgentResponse.Result Unwrapping Tests + + /// + /// Verifies that AgentResponse{T}.Result correctly deserializes an object without unwrapping. + /// + [Fact] + public void AgentResponseGeneric_Result_DeserializesObjectWithoutUnwrapping() + { + // Arrange + Animal expectedAnimal = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal); + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options); + + // Act + Animal result = typedResponse.Result; + + // Assert + Assert.Equal(expectedAnimal.Id, result.Id); + Assert.Equal(expectedAnimal.FullName, result.FullName); + Assert.Equal(expectedAnimal.Species, result.Species); + } + + /// + /// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes a primitive value. + /// + [Fact] + public void AgentResponseGeneric_Result_UnwrapsPrimitiveFromDataProperty() + { + // Arrange + const string ResponseJson = "{\"data\":42}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true }; + + // Act + int result = typedResponse.Result; + + // Assert + Assert.Equal(42, result); + } + + /// + /// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an array. + /// + [Fact] + public void AgentResponseGeneric_Result_UnwrapsArrayFromDataProperty() + { + // Arrange + const string ResponseJson = "{\"data\":[\"apple\",\"banana\",\"cherry\"]}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true }; + + // Act + string[] result = typedResponse.Result; + + // Assert + Assert.Equal(["apple", "banana", "cherry"], result); + } + + /// + /// Verifies that AgentResponse{T}.Result correctly unwraps and deserializes an enum. + /// + [Fact] + public void AgentResponseGeneric_Result_UnwrapsEnumFromDataProperty() + { + // Arrange + const string ResponseJson = "{\"data\":\"Walrus\"}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true }; + + // Act + Species result = typedResponse.Result; + + // Assert + Assert.Equal(Species.Walrus, result); + } + + /// + /// Verifies that AgentResponse{T}.Result falls back to original JSON when data property is missing. + /// + [Fact] + public void AgentResponseGeneric_Result_FallsBackWhenDataPropertyMissing() + { + // Arrange - simulate a case where wrapping was expected but response does not have data + const string ResponseJson = "42"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options) { IsWrappedInObject = true }; + + // Act + int result = typedResponse.Result; + + // Assert - should still work by falling back to original JSON + Assert.Equal(42, result); + } + + /// + /// Verifies that AgentResponse{T}.Result throws when response text is empty. + /// + [Fact] + public void AgentResponseGeneric_Result_ThrowsWhenTextIsEmpty() + { + // Arrange + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, string.Empty)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options); + + // Act and Assert + Assert.Throws(() => typedResponse.Result); + } + + /// + /// Verifies that AgentResponse{T}.Result throws when deserialized value is null. + /// + [Fact] + public void AgentResponseGeneric_Result_ThrowsWhenDeserializedValueIsNull() + { + // Arrange + const string ResponseJson = "null"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + AgentResponse typedResponse = new(response, TestJsonSerializerContext.Default.Options); + + // Act and Assert + Assert.Throws(() => typedResponse.Result); + } + + #endregion + + #region End-to-End Tests + + /// + /// End-to-end test: Request a primitive type, verify wrapping, and verify correct deserialization. + /// + [Fact] + public async Task RunAsyncGeneric_PrimitiveEndToEnd_WrapsAndDeserializesCorrectlyAsync() + { + // Arrange + const string ResponseJson = "{\"data\":123}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me a number", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert + Assert.True(result.IsWrappedInObject); + Assert.Equal(123, result.Result); + } + + /// + /// End-to-end test: Request an array type, verify wrapping, and verify correct deserialization. + /// + [Fact] + public async Task RunAsyncGeneric_ArrayEndToEnd_WrapsAndDeserializesCorrectlyAsync() + { + // Arrange + const string ResponseJson = "{\"data\":[\"one\",\"two\",\"three\"]}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me an array of strings", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert + Assert.True(result.IsWrappedInObject); + Assert.Equal(["one", "two", "three"], result.Result); + } + + /// + /// End-to-end test: Request an object type, verify no wrapping, and verify correct deserialization. + /// + [Fact] + public async Task RunAsyncGeneric_ObjectEndToEnd_NoWrappingAndDeserializesCorrectlyAsync() + { + // Arrange + Animal expectedAnimal = new() { Id = 99, FullName = "Leo", Species = Species.Bear }; + string responseJson = JsonSerializer.Serialize(expectedAnimal, TestJsonSerializerContext.Default.Animal); + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, responseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me an animal", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert + Assert.False(result.IsWrappedInObject); + Assert.Equal(expectedAnimal.Id, result.Result.Id); + Assert.Equal(expectedAnimal.FullName, result.Result.FullName); + Assert.Equal(expectedAnimal.Species, result.Result.Species); + } + + /// + /// End-to-end test: Request an enum type, verify wrapping, and verify correct deserialization. + /// + [Fact] + public async Task RunAsyncGeneric_EnumEndToEnd_WrapsAndDeserializesCorrectlyAsync() + { + // Arrange + const string ResponseJson = "{\"data\":\"Bear\"}"; + AgentResponse response = new(new ChatMessage(ChatRole.Assistant, ResponseJson)); + + this._agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + // Act + AgentResponse result = await this._agentMock.Object.RunAsync( + "Give me a species", + serializerOptions: TestJsonSerializerContext.Default.Options); + + // Assert + Assert.True(result.IsWrappedInObject); + Assert.Equal(Species.Bear, result.Result); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs index 8300701c5b..e1425b3144 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentResponseTests.cs @@ -214,30 +214,6 @@ public void ToAgentResponseUpdatesProducesUpdates() Assert.Equal(100, usageContent.Details.TotalTokenCount); } -#if NETFRAMEWORK - /// - /// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't - /// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based - /// serialization is available. - /// - [Fact] - public void ParseAsStructuredOutputSuccess() - { - // Arrange. - var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal))); - - // Act. - var animal = response.Deserialize(); - - // Assert. - Assert.NotNull(animal); - Assert.Equal(expectedResult.Id, animal.Id); - Assert.Equal(expectedResult.FullName, animal.FullName); - Assert.Equal(expectedResult.Species, animal.Species); - } -#endif - [Fact] public void ParseAsStructuredOutputWithJSOSuccess() { @@ -246,7 +222,7 @@ public void ParseAsStructuredOutputWithJSOSuccess() var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal))); // Act. - var animal = response.Deserialize(TestJsonSerializerContext.Default.Options); + var animal = JsonSerializer.Deserialize(response.Text, TestJsonSerializerContext.Default.Options); // Assert. Assert.NotNull(animal); @@ -255,98 +231,6 @@ public void ParseAsStructuredOutputWithJSOSuccess() Assert.Equal(expectedResult.Species, animal.Species); } - [Fact] - public void ParseAsStructuredOutputFailsWithEmptyString() - { - // Arrange. - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty)); - - // Act & Assert. - var exception = Assert.Throws(() => response.Deserialize(TestJsonSerializerContext.Default.Options)); - Assert.Equal("The response did not contain JSON to be deserialized.", exception.Message); - } - - [Fact] - public void ParseAsStructuredOutputFailsWithInvalidJson() - { - // Arrange. - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "invalid json")); - - // Act & Assert. - Assert.Throws(() => response.Deserialize(TestJsonSerializerContext.Default.Options)); - } - - [Fact] - public void ParseAsStructuredOutputFailsWithIncorrectTypedJson() - { - // Arrange. - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]")); - - // Act & Assert. - Assert.Throws(() => response.Deserialize(TestJsonSerializerContext.Default.Options)); - } - -#if NETFRAMEWORK - /// - /// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't - /// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based - /// serialization is available. - /// - [Fact] - public void TryParseAsStructuredOutputSuccess() - { - // Arrange. - var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal))); - - // Act. - response.TryDeserialize(out Animal? animal); - - // Assert. - Assert.NotNull(animal); - Assert.Equal(expectedResult.Id, animal.Id); - Assert.Equal(expectedResult.FullName, animal.FullName); - Assert.Equal(expectedResult.Species, animal.Species); - } -#endif - - [Fact] - public void TryParseAsStructuredOutputWithJSOSuccess() - { - // Arrange. - var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal))); - - // Act. - response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal); - - // Assert. - Assert.NotNull(animal); - Assert.Equal(expectedResult.Id, animal.Id); - Assert.Equal(expectedResult.FullName, animal.FullName); - Assert.Equal(expectedResult.Species, animal.Species); - } - - [Fact] - public void TryParseAsStructuredOutputFailsWithEmptyText() - { - // Arrange. - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, string.Empty)); - - // Act & Assert. - Assert.False(response.TryDeserialize(TestJsonSerializerContext.Default.Options, out _)); - } - - [Fact] - public void TryParseAsStructuredOutputFailsWithIncorrectTypedJson() - { - // Arrange. - var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, "[]")); - - // Act & Assert. - Assert.False(response.TryDeserialize(TestJsonSerializerContext.Default.Options, out _)); - } - [Fact] public void ToAgentResponseUpdatesWithNoMessagesProducesEmptyArray() { @@ -395,16 +279,4 @@ public void ToAgentResponseUpdatesWithAdditionalPropertiesOnlyProducesSingleUpda Assert.NotNull(update.AdditionalProperties); Assert.Equal("value", update.AdditionalProperties!["key"]); } - - [Fact] - public void Deserialize_ThrowsWhenDeserializationReturnsNull() - { - // Arrange - AgentResponse response = new(new ChatMessage(ChatRole.Assistant, "null")); - - // Act & Assert - InvalidOperationException exception = Assert.Throws( - () => response.Deserialize(TestJsonSerializerContext.Default.Options)); - Assert.Equal("The deserialized response is null.", exception.Message); - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs index 7460ea4623..028828c520 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Text.Json; using Microsoft.Extensions.AI; @@ -27,7 +26,7 @@ public void CloningConstructorCopiesProperties() }; // Act - var clone = new AgentRunOptions(options); + var clone = options.Clone(); // Assert Assert.NotNull(clone); @@ -39,11 +38,6 @@ public void CloningConstructorCopiesProperties() Assert.Equal(42, clone.AdditionalProperties["key2"]); } - [Fact] - public void CloningConstructorThrowsIfNull() => - // Act & Assert - Assert.Throws(() => new AgentRunOptions(null!)); - [Fact] public void JsonSerializationRoundtrips() { @@ -77,4 +71,57 @@ public void JsonSerializationRoundtrips() Assert.IsType(value2); Assert.Equal(42, ((JsonElement)value2!).GetInt32()); } + + [Fact] + public void CloneReturnsNewInstanceWithSameValues() + { + // Arrange + var options = new AgentRunOptions + { + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + AllowBackgroundResponses = true, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + }, + ResponseFormat = ChatResponseFormat.Json + }; + + // Act + AgentRunOptions clone = options.Clone(); + + // Assert + Assert.NotNull(clone); + Assert.IsType(clone); + Assert.NotSame(options, clone); + Assert.Same(options.ContinuationToken, clone.ContinuationToken); + Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses); + Assert.NotNull(clone.AdditionalProperties); + Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value1", clone.AdditionalProperties["key1"]); + Assert.Equal(42, clone.AdditionalProperties["key2"]); + Assert.Same(options.ResponseFormat, clone.ResponseFormat); + } + + [Fact] + public void CloneCreatesIndependentAdditionalPropertiesDictionary() + { + // Arrange + var options = new AgentRunOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1" + } + }; + + // Act + AgentRunOptions clone = options.Clone(); + clone.AdditionalProperties!["key2"] = "value2"; + + // Assert + Assert.True(clone.AdditionalProperties.ContainsKey("key2")); + Assert.False(options.AdditionalProperties.ContainsKey("key2")); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs index c4f3b7511a..3de33e31d9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/TestJsonSerializerContext.cs @@ -15,6 +15,7 @@ namespace Microsoft.Agents.AI.Abstractions.UnitTests; [JsonSerializable(typeof(AgentResponseUpdate))] [JsonSerializable(typeof(AgentRunOptions))] [JsonSerializable(typeof(Animal))] +[JsonSerializable(typeof(Species))] [JsonSerializable(typeof(JsonElement))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(string[]))] diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs new file mode 100644 index 0000000000..77012f4957 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/DurableAgentRunOptionsTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class DurableAgentRunOptionsTests +{ + [Fact] + public void CloneReturnsNewInstanceWithSameValues() + { + // Arrange + DurableAgentRunOptions options = new() + { + EnableToolCalls = false, + EnableToolNames = new List { "tool1", "tool2" }, + IsFireAndForget = true, + AllowBackgroundResponses = true, + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1", + ["key2"] = 42 + }, + ResponseFormat = ChatResponseFormat.Json + }; + + // Act + AgentRunOptions cloneAsBase = options.Clone(); + + // Assert + Assert.NotNull(cloneAsBase); + Assert.IsType(cloneAsBase); + DurableAgentRunOptions clone = (DurableAgentRunOptions)cloneAsBase; + Assert.NotSame(options, clone); + Assert.Equal(options.EnableToolCalls, clone.EnableToolCalls); + Assert.NotNull(clone.EnableToolNames); + Assert.NotSame(options.EnableToolNames, clone.EnableToolNames); + Assert.Equal(2, clone.EnableToolNames.Count); + Assert.Contains("tool1", clone.EnableToolNames); + Assert.Contains("tool2", clone.EnableToolNames); + Assert.Equal(options.IsFireAndForget, clone.IsFireAndForget); + Assert.Equal(options.AllowBackgroundResponses, clone.AllowBackgroundResponses); + Assert.Same(options.ContinuationToken, clone.ContinuationToken); + Assert.NotNull(clone.AdditionalProperties); + Assert.NotSame(options.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value1", clone.AdditionalProperties["key1"]); + Assert.Equal(42, clone.AdditionalProperties["key2"]); + Assert.Same(options.ResponseFormat, clone.ResponseFormat); + } + + [Fact] + public void CloneCreatesIndependentEnableToolNamesList() + { + // Arrange + DurableAgentRunOptions options = new() + { + EnableToolNames = new List { "tool1" } + }; + + // Act + DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone(); + clone.EnableToolNames!.Add("tool2"); + + // Assert + Assert.Equal(2, clone.EnableToolNames.Count); + Assert.Single(options.EnableToolNames); + Assert.DoesNotContain("tool2", options.EnableToolNames); + } + + [Fact] + public void CloneCreatesIndependentAdditionalPropertiesDictionary() + { + // Arrange + DurableAgentRunOptions options = new() + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1" + } + }; + + // Act + DurableAgentRunOptions clone = (DurableAgentRunOptions)options.Clone(); + clone.AdditionalProperties!["key2"] = "value2"; + + // Assert + Assert.True(clone.AdditionalProperties.ContainsKey("key2")); + Assert.False(options.AdditionalProperties.ContainsKey("key2")); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs index 1aa49dc328..7a00a9f796 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentRunOptionsTests.cs @@ -332,4 +332,91 @@ await Assert.ThrowsAsync(async () => } #endregion + + #region Clone Tests + + /// + /// Verify that Clone returns a new instance with the same property values. + /// + [Fact] + public void CloneReturnsNewInstanceWithSameValues() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f }; + Func factory = c => c; + var runOptions = new ChatClientAgentRunOptions(chatOptions) + { + ChatClientFactory = factory, + AllowBackgroundResponses = true, + ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }), + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1" + } + }; + + // Act + AgentRunOptions cloneAsBase = runOptions.Clone(); + + // Assert + Assert.NotNull(cloneAsBase); + Assert.IsType(cloneAsBase); + ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)cloneAsBase; + Assert.NotSame(runOptions, clone); + Assert.NotNull(clone.ChatOptions); + Assert.NotSame(runOptions.ChatOptions, clone.ChatOptions); + Assert.Equal(100, clone.ChatOptions!.MaxOutputTokens); + Assert.Equal(0.7f, clone.ChatOptions.Temperature); + Assert.Same(factory, clone.ChatClientFactory); + Assert.Equal(runOptions.AllowBackgroundResponses, clone.AllowBackgroundResponses); + Assert.Same(runOptions.ContinuationToken, clone.ContinuationToken); + Assert.NotNull(clone.AdditionalProperties); + Assert.NotSame(runOptions.AdditionalProperties, clone.AdditionalProperties); + Assert.Equal("value1", clone.AdditionalProperties["key1"]); + } + + /// + /// Verify that modifying the cloned ChatOptions does not affect the original. + /// + [Fact] + public void CloneCreatesIndependentChatOptions() + { + // Arrange + var chatOptions = new ChatOptions { MaxOutputTokens = 100 }; + var runOptions = new ChatClientAgentRunOptions(chatOptions); + + // Act + ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone(); + clone.ChatOptions!.MaxOutputTokens = 200; + + // Assert + Assert.Equal(100, runOptions.ChatOptions!.MaxOutputTokens); + Assert.Equal(200, clone.ChatOptions.MaxOutputTokens); + } + + /// + /// Verify that modifying the cloned AdditionalProperties does not affect the original. + /// + [Fact] + public void CloneCreatesIndependentAdditionalPropertiesDictionary() + { + // Arrange + var runOptions = new ChatClientAgentRunOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["key1"] = "value1" + } + }; + + // Act + ChatClientAgentRunOptions clone = (ChatClientAgentRunOptions)runOptions.Clone(); + clone.AdditionalProperties!["key2"] = "value2"; + + // Assert + Assert.True(clone.AdditionalProperties.ContainsKey("key2")); + Assert.False(runOptions.AdditionalProperties.ContainsKey("key2")); + } + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs index 7b33cbbd5f..d90fe5153a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgentTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -943,45 +942,6 @@ public async Task RunStreamingAsyncInvokesMultipleAIContextProvidersAsync() #endregion - #region RunAsync Structured Output Tests - - /// - /// Verify the invocation of with specified type parameter is - /// propagated to the underlying call and the expected structured output is returned. - /// - [Fact] - public async Task RunAsyncWithTypeParameterInvokesChatClientMethodForStructuredOutputAsync() - { - // Arrange - Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; - - Mock mockService = new(); - mockService.Setup(s => s - .GetResponseAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny())) - .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext2.Default.Animal))) - { - ResponseId = "test", - }); - - ChatClientAgent agent = new(mockService.Object, options: new()); - - // Act - AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], serializerOptions: JsonContext2.Default.Options); - - // Assert - Assert.Single(agentResponse.Messages); - - Assert.NotNull(agentResponse.Result); - Assert.Equal(expectedSO.Id, agentResponse.Result.Id); - Assert.Equal(expectedSO.FullName, agentResponse.Result.FullName); - Assert.Equal(expectedSO.Species, agentResponse.Result.Species); - } - - #endregion - #region Property Override Tests /// @@ -1999,20 +1959,6 @@ private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = responseFormat + } + }); + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_IsPropagatedToChatClientAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = responseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(responseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentInvocation_OverridesOneProvidedAtAgentInitializationAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson initializationResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson invocationResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = initializationResponseFormat + }, + }); + + ChatClientAgentRunOptions runOptions = new() + { + ResponseFormat = invocationResponseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(invocationResponseFormat, capturedResponseFormat); + Assert.NotSame(initializationResponseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_ResponseFormatProvidedAtAgentRunOptions_OverridesOneProvidedViaChatOptionsAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "test")) + { + ResponseId = "test", + }); + + ChatResponseFormatJson chatOptionsResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + ChatResponseFormatJson runOptionsResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object); + + ChatClientAgentRunOptions runOptions = new() + { + ChatOptions = new ChatOptions + { + ResponseFormat = chatOptionsResponseFormat + }, + ResponseFormat = runOptionsResponseFormat + }; + + // Act + await agent.RunAsync(messages: [new(ChatRole.User, "Hello")], options: runOptions); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Same(runOptionsResponseFormat, capturedResponseFormat); + Assert.NotSame(chatOptionsResponseFormat, capturedResponseFormat); + } + + [Fact] + public async Task RunAsync_StructuredOutputResponse_IsAvailableAsTextOnAgentResponseAsync() + { + // Arrange + Animal expectedAnimal = new() { FullName = "Wally the Walrus", Id = 1, Species = Species.Walrus }; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedAnimal, JsonContext4.Default.Animal))) + { + ResponseId = "test", + }); + + ChatResponseFormatJson responseFormat = ChatResponseFormat.ForJsonSchema(JsonContext4.Default.Options); + + ChatClientAgent agent = new(mockService.Object, options: new ChatClientAgentOptions + { + ChatOptions = new ChatOptions() + { + ResponseFormat = responseFormat + }, + }); + + // Act + AgentResponse agentResponse = await agent.RunAsync(messages: [new(ChatRole.User, "Hello")]); + + // Assert + Assert.NotNull(agentResponse?.Text); + + Animal? deserialised = JsonSerializer.Deserialize(agentResponse.Text, JsonContext4.Default.Animal); + Assert.NotNull(deserialised); + Assert.Equal(expectedAnimal.Id, deserialised.Id); + Assert.Equal(expectedAnimal.FullName, deserialised.FullName); + Assert.Equal(expectedAnimal.Species, deserialised.Species); + } + + [JsonSerializable(typeof(Animal))] + private sealed partial class JsonContext4 : JsonSerializerContext; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs new file mode 100644 index 0000000000..57e1bbf371 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_StructuredOutput_WithRunAsyncTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +public partial class ChatClientAgent_StructuredOutput_WithRunAsyncTests +{ + [Fact] + public async Task RunAsync_WithGenericType_SetsJsonSchemaResponseFormatAndDeserializesResultAsync() + { + // Arrange + ChatResponseFormat? capturedResponseFormat = null; + ChatResponseFormatJson expectedResponseFormat = ChatResponseFormat.ForJsonSchema(JsonContext3.Default.Options); + Animal expectedSO = new() { Id = 1, FullName = "Tigger", Species = Species.Tiger }; + + Mock mockService = new(); + mockService.Setup(s => s + .GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, ChatOptions, CancellationToken>((msgs, opts, ct) => capturedResponseFormat = opts?.ResponseFormat) + .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedSO, JsonContext3.Default.Animal))) + { + ResponseId = "test", + }); + + ChatClientAgent agent = new(mockService.Object); + + // Act + AgentResponse agentResponse = await agent.RunAsync( + messages: [new(ChatRole.User, "Hello")], + serializerOptions: JsonContext3.Default.Options); + + // Assert + Assert.NotNull(capturedResponseFormat); + Assert.Equal(expectedResponseFormat.Schema?.GetRawText(), ((ChatResponseFormatJson)capturedResponseFormat).Schema?.GetRawText()); + + Animal animal = agentResponse.Result; + Assert.NotNull(animal); + Assert.Equal(expectedSO.Id, animal.Id); + Assert.Equal(expectedSO.FullName, animal.FullName); + Assert.Equal(expectedSO.Species, animal.Species); + } + + [JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(Animal))] + private sealed partial class JsonContext3 : JsonSerializerContext; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs new file mode 100644 index 0000000000..331f336b8e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Animal.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +internal sealed class Animal +{ + public int Id { get; set; } + public string? FullName { get; set; } + public Species Species { get; set; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs new file mode 100644 index 0000000000..14c493be72 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/Models/Species.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests; + +internal enum Species +{ + Bear, + Tiger, + Walrus, +} diff --git a/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs new file mode 100644 index 0000000000..caa42ecc8d --- /dev/null +++ b/dotnet/tests/OpenAIAssistant.IntegrationTests/OpenAIAssistantStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace OpenAIAssistant.IntegrationTests; + +public class OpenAIAssistantStructuredOutputRunTests() : StructuredOutputRunTests(() => new()) +{ +} diff --git a/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs new file mode 100644 index 0000000000..b7c66f1f26 --- /dev/null +++ b/dotnet/tests/OpenAIChatCompletion.IntegrationTests/OpenAIChatCompletionStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace OpenAIChatCompletion.IntegrationTests; + +public class OpenAIChatCompletionStructuredOutputRunTests() : StructuredOutputRunTests(() => new(useReasoningChatModel: false)) +{ +} diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs new file mode 100644 index 0000000000..497c16eb5a --- /dev/null +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseStructuredOutputRunTests.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft. All rights reserved. + +using AgentConformance.IntegrationTests; + +namespace ResponseResult.IntegrationTests; + +public class OpenAIResponseStructuredOutputRunTests() : StructuredOutputRunTests(() => new(store: false)) +{ +}