You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
.NET: [Feature]: Accept AIJsonSchemaCreateOptions for the structured-output schema of AIAgent.RunAsync<T> #9245
AIAgent.RunAsync<T> creates the response schema with ChatResponseFormat.ForJsonSchema<T>(serializerOptions) and then overwrites AgentRunOptions.ResponseFormat with it (AIAgentStructuredOutput.cs L127-L134; ChatClientAgent's overloads that take ChatClientAgentRunOptions forward to it, ChatClientAgentCustomOptions.cs L167-L232). An application cannot pass the AIJsonSchemaCreateOptions (a TransformSchemaNode) that it can already pass to AIFunctionFactory for its tools, and a ResponseFormat it sets itself is replaced, as ADR 0036 intends: RunAsync<T> owns the schema. This request is for a way to influence how it creates that schema, not to bypass it.
For a type serialized by a custom JsonConverter (a strongly typed ID, a value object such as an IBAN), the schema then says true for the property and {} for the items of a list of them: it does not even say the value is a string.
Use Case and Evidence
Found while adding Microsoft.Extensions.AI support to AdCodicem.ValueObjects, a library of value objects serialized by their own converters, and checking how its schemas reach an agent's structured output. An Order record holds an Iban, a struct written as a JSON string of 15 to 34 characters by its own converter, and a List<Iban>. The application has a TransformSchemaNode that describes Iban as {"type":"string","minLength":15,"maxLength":34,"pattern":"..."}. The repro calls RunAsync<Order> on a ChatClientAgent over a scripted IChatClient that records the ChatOptions.ResponseFormat it receives.
Output (Microsoft.Agents.AI 1.24.0, Microsoft.Extensions.AI 10.10.0, Microsoft.Extensions.AI.Abstractions 10.10.1, .NET 10.0.12, Windows 11 x64; same output on Ubuntu 24.04 x64):
agent.RunAsync<Order>(...): format the IChatClient receives
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":true,"quantity":{"type":"integer"},"alternates":{"type":"array","items":{}}},"required":["account","quantity","alternates"]}
Result.Account = FR7630006000011234567890189
agent.RunAsync<Order>(..., options: new AgentRunOptions { ResponseFormat = wanted }): format the IChatClient receives
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":true,"quantity":{"type":"integer"},"alternates":{"type":"array","items":{}}},"required":["account","quantity","alternates"]}
is it the caller's format? False
Wanted: AIJsonUtilities.CreateJsonSchema(typeof(Order), ..., inferenceOptions: schemaOptions)
{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"account":{"type":"string","minLength":15,"maxLength":34,"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"},"quantity":{"type":"integer"},"alternates":{"type":"array","items":{"type":"string","minLength":15,"maxLength":34,"pattern":"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"}}},"required":["account","quantity","alternates"]}
Workaround (non-generic RunAsync + new AgentResponse<Order>): format received is the caller's? True; Result.Account = FR7630006000011234567890189
Repro (Program.cs, Repro.csproj): run with dotnet run
// Program.csusingSystem.Text.Json;usingSystem.Text.Json.Nodes;usingSystem.Text.Json.Serialization;usingMicrosoft.Agents.AI;usingMicrosoft.Extensions.AI;conststringAnswer="""{"account":"FR7630006000011234567890189","quantity":3,"alternates":[]}""";JsonSerializerOptionsserializerOptions=AgentAbstractionsJsonUtilities.DefaultOptions;// what RunAsync<T> uses by default// The format the application would like RunAsync<T> to send: Iban described with the rules its converter enforces.varschemaOptions=newAIJsonSchemaCreateOptions{IncludeSchemaKeyword=true,TransformSchemaNode=DescribeIban};ChatResponseFormatJsonwanted=ChatResponseFormat.ForJsonSchema(AIJsonUtilities.CreateJsonSchema(typeof(Order),serializerOptions:serializerOptions,inferenceOptions:schemaOptions),"Order");// 1. RunAsync<T>: the format the IChatClient receives.varclient=newScriptedChatClient(Answer);AIAgentagent=client.AsAIAgent(instructions:"You place orders.");AgentResponse<Order>typed=awaitagent.RunAsync<Order>("Place an order.");Print("agent.RunAsync<Order>(...): format the IChatClient receives",client.LastFormat!.Schema!.Value);Console.WriteLine($" Result.Account = {typed.Result.Account}");Console.WriteLine();// 2. A ResponseFormat the caller sets on AgentRunOptions is replaced by RunAsync<T>.typed=awaitagent.RunAsync<Order>("Place an order.",options:newAgentRunOptions{ResponseFormat=wanted});Print("agent.RunAsync<Order>(..., options: new AgentRunOptions { ResponseFormat = wanted }): format the IChatClient receives",client.LastFormat!.Schema!.Value);Console.WriteLine($" is it the caller's format? {ReferenceEquals(client.LastFormat,wanted)}");Console.WriteLine();// 3. What the application wants sent.Print("Wanted: AIJsonUtilities.CreateJsonSchema(typeof(Order), ..., inferenceOptions: schemaOptions)",wanted.Schema!.Value);// 4. Today's workaround: the non-generic RunAsync with the format, then AgentResponse<T> by hand.AgentResponseraw=awaitagent.RunAsync("Place an order.",options:newAgentRunOptions{ResponseFormat=wanted});varwrapped=newAgentResponse<Order>(raw,serializerOptions);Console.WriteLine($"Workaround (non-generic RunAsync + new AgentResponse<Order>): format received is the caller's? "+$"{ReferenceEquals(client.LastFormat,wanted)}; Result.Account = {wrapped.Result.Account}");staticJsonNodeDescribeIban(AIJsonSchemaCreateContextcontext,JsonNodeschema){// The schema of a type with a custom converter is `true` (or an object holding only a description).if(context.TypeInfo.Type==typeof(Iban)){returnDescribe(schemaasJsonObject??[]);}// System.Text.Json's exporter does not call the transform for a collection element whose schema is `true`, so the// collection's own node completes its `items`.if(context.TypeInfo.ElementType==typeof(Iban)&&schemaisJsonObjectcollection){if(collection["items"]isJsonObjectitems){Describe(items);}else{collection["items"]=Describe([]);}}returnschema;staticJsonObjectDescribe(JsonObjectnode){node["type"]="string";node["minLength"]=15;node["maxLength"]=34;node["pattern"]="^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$";returnnode;}}staticvoidPrint(stringtitle,JsonElementschema){Console.WriteLine(title);Console.WriteLine(" "+JsonSerializer.Serialize(schema));}/// <summary>A value object: serialized as a JSON string by its own converter, which enforces its rules.</summary>[JsonConverter(typeof(IbanJsonConverter))]publicreadonlystructIban(stringvalue){publicstringValue{get;}=value;publicoverridestringToString()=>Value;}publicsealedclassIbanJsonConverter:JsonConverter<Iban>{publicoverrideIbanRead(refUtf8JsonReaderreader,TypetypeToConvert,JsonSerializerOptionsoptions){if(reader.TokenType!=JsonTokenType.String){thrownewJsonException("An IBAN is a JSON string.");}stringtext=reader.GetString()!;if(text.Lengthis<15 or >34){thrownewJsonException("An IBAN has 15 to 34 characters.");}returnnewIban(text);}publicoverridevoidWrite(Utf8JsonWriterwriter,Ibanvalue,JsonSerializerOptionsoptions)=>writer.WriteStringValue(value.Value);}publicsealedrecordOrder(IbanAccount,intQuantity,List<Iban>Alternates);/// <summary>Stands for the model: records the response format it is sent, answers with a fixed text.</summary>publicsealedclassScriptedChatClient(stringanswer):IChatClient{publicChatResponseFormatJson?LastFormat{get;privateset;}publicTask<ChatResponse>GetResponseAsync(IEnumerable<ChatMessage>messages,ChatOptions?options=null,CancellationTokencancellationToken=default){LastFormat=options?.ResponseFormatasChatResponseFormatJson;returnTask.FromResult(newChatResponse(newChatMessage(ChatRole.Assistant,answer)));}publicIAsyncEnumerable<ChatResponseUpdate>GetStreamingResponseAsync(IEnumerable<ChatMessage>messages,ChatOptions?options=null,CancellationTokencancellationToken=default)=>thrownewNotSupportedException();publicobject?GetService(TypeserviceType,object?serviceKey=null)=>null;publicvoidDispose(){}}
AI assistance: the analysis, the reproduction and this text were prepared with Claude (Anthropic). The reproduction was run on Windows 11 and on Ubuntu 24.04, with the same output, and I reviewed the report, its links and its claims before filing.
Desired Outcome
RunAsync<T> can be given the AIJsonSchemaCreateOptions to create its schema with, so that the same options describe an application's types in its tools and in its agents' structured output, with RunAsync<T> still wrapping a non-object schema and reading the answer as it does today.
Alternatives Considered
The non-generic RunAsync with a hand-built format, then new AgentResponse<T>(response, serializerOptions) (the "Workaround" line of the output). It works for a T whose schema is an object. For any other T the application must also wrap the schema the way RunAsync<T> does, since StructuredOutputSchemaUtilities is internal (StructuredOutputSchemaUtilities.cs L15), and set IsWrappedInObject, which is public here, unlike in Microsoft.Extensions.AI.
Setting AgentRunOptions.ResponseFormat and calling RunAsync<T>: replaced (second block of the output).
Either of these would do; the second keeps the overload count unchanged.
An overload of RunAsync<T> that takes AIJsonSchemaCreateOptions? schemaCreateOptions.
A property on AgentRunOptions, for instance AIJsonSchemaCreateOptions? ResponseSchemaCreateOptions, which RunAsync<T> reads when it creates the format.
RunAsync<T> can build the schema today with AIJsonUtilities.CreateJsonSchema(typeof(T), serializerOptions: serializerOptions, inferenceOptions: ...) and ChatResponseFormat.ForJsonSchema(JsonElement, name, description), both public, or with the overload proposed to Microsoft.Extensions.AI in dotnet/extensions#7821, if it is accepted.
Scope and Non-Goals
Only the schema RunAsync<T> sends. Reading and validating the answer is unchanged, and so is the default schema when no options are given.
Language
.NET
Contribution Intent
I am only requesting the feature
Acknowledgements
I searched existing issues and did not find a duplicate.
I will wait for explicit maintainer agreement before starting implementation of a non-trivial change.
changed the title [-][Feature]: Accept AIJsonSchemaCreateOptions for the structured-output schema of AIAgent.RunAsync<T>[/-][+].NET: [Feature]: Accept AIJsonSchemaCreateOptions for the structured-output schema of AIAgent.RunAsync<T>[/+]on Oct 10, 2026
Problem
AIAgent.RunAsync<T>creates the response schema withChatResponseFormat.ForJsonSchema<T>(serializerOptions)and then overwritesAgentRunOptions.ResponseFormatwith it (AIAgentStructuredOutput.csL127-L134;ChatClientAgent's overloads that takeChatClientAgentRunOptionsforward to it,ChatClientAgentCustomOptions.csL167-L232). An application cannot pass theAIJsonSchemaCreateOptions(aTransformSchemaNode) that it can already pass toAIFunctionFactoryfor its tools, and aResponseFormatit sets itself is replaced, as ADR 0036 intends:RunAsync<T>owns the schema. This request is for a way to influence how it creates that schema, not to bypass it.For a type serialized by a custom
JsonConverter(a strongly typed ID, a value object such as an IBAN), the schema then saystruefor the property and{}for the items of a list of them: it does not even say the value is a string.Use Case and Evidence
Found while adding Microsoft.Extensions.AI support to AdCodicem.ValueObjects, a library of value objects serialized by their own converters, and checking how its schemas reach an agent's structured output. An
Orderrecord holds anIban, a struct written as a JSON string of 15 to 34 characters by its own converter, and aList<Iban>. The application has aTransformSchemaNodethat describesIbanas{"type":"string","minLength":15,"maxLength":34,"pattern":"..."}. The repro callsRunAsync<Order>on aChatClientAgentover a scriptedIChatClientthat records theChatOptions.ResponseFormatit receives.Output (Microsoft.Agents.AI 1.24.0, Microsoft.Extensions.AI 10.10.0, Microsoft.Extensions.AI.Abstractions 10.10.1, .NET 10.0.12, Windows 11 x64; same output on Ubuntu 24.04 x64):
Repro (Program.cs, Repro.csproj): run with
dotnet runThe same as a project with a script: https://github.com/AdCodicem/dotnet-upstream-repros/tree/main/agent-framework-runasync-schema-options
AI assistance: the analysis, the reproduction and this text were prepared with Claude (Anthropic). The reproduction was run on Windows 11 and on Ubuntu 24.04, with the same output, and I reviewed the report, its links and its claims before filing.
Desired Outcome
RunAsync<T>can be given theAIJsonSchemaCreateOptionsto create its schema with, so that the same options describe an application's types in its tools and in its agents' structured output, withRunAsync<T>still wrapping a non-object schema and reading the answer as it does today.Alternatives Considered
RunAsyncwith a hand-built format, thennew AgentResponse<T>(response, serializerOptions)(the "Workaround" line of the output). It works for aTwhose schema is an object. For any otherTthe application must also wrap the schema the wayRunAsync<T>does, sinceStructuredOutputSchemaUtilitiesis internal (StructuredOutputSchemaUtilities.csL15), and setIsWrappedInObject, which is public here, unlike in Microsoft.Extensions.AI.AgentRunOptions.ResponseFormatand callingRunAsync<T>: replaced (second block of the output).[StringLength],[RegularExpression]): Microsoft.Extensions.AI adds the keywords but not"type", and on a list property they describe the list, not its items (shown in [API Proposal]: Accept AIJsonSchemaCreateOptions in ChatResponseFormat.ForJsonSchema<T> and GetResponseAsync<T> dotnet/extensions#7821).Proposed Direction
Either of these would do; the second keeps the overload count unchanged.
RunAsync<T>that takesAIJsonSchemaCreateOptions? schemaCreateOptions.AgentRunOptions, for instanceAIJsonSchemaCreateOptions? ResponseSchemaCreateOptions, whichRunAsync<T>reads when it creates the format.RunAsync<T>can build the schema today withAIJsonUtilities.CreateJsonSchema(typeof(T), serializerOptions: serializerOptions, inferenceOptions: ...)andChatResponseFormat.ForJsonSchema(JsonElement, name, description), both public, or with the overload proposed to Microsoft.Extensions.AI in dotnet/extensions#7821, if it is accepted.Scope and Non-Goals
Only the schema
RunAsync<T>sends. Reading and validating the answer is unchanged, and so is the default schema when no options are given.Language
.NET
Contribution Intent
I am only requesting the feature
Acknowledgements