Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 164 additions & 1 deletion agent/format/jsonformat/jsonformat.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package jsonformat

import (
"encoding/json"
"fmt"
"reflect"
"sort"
"strings"
"sync"

Expand All @@ -23,12 +25,34 @@ type Format struct {

func (f *Format) resolvedSchema() (*jsonschema.Resolved, error) {
f.resolvedOnce.Do(func() {
f.resolved, f.resolvedErr = f.Schema.(*jsonschema.Schema).Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
schema, ok := f.Schema.(*jsonschema.Schema)
if !ok {
f.resolvedErr = fmt.Errorf("response format schema has type %T, want *jsonschema.Schema", f.Schema)
return
}
// The stored schema is strict (every property is required, with optional
// properties made nullable). That form is required by OpenAI when sending,
// but it is too rigid for local validation of Go values, which omit
// omitempty fields. Relax the strict-only requirements before resolving so
// validation keeps matching the original optionality.
validationSchema, err := relaxStrict(schema)
if err != nil {
f.resolvedErr = err
return
}
f.resolved, f.resolvedErr = validationSchema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true})
})
return f.resolved, f.resolvedErr
}

func newFormat(name, description string, schema *jsonschema.Schema) *Format {
// OpenAI strict structured outputs require every key present in an object's
// "properties" to also appear in its "required" array. The jsonschema-go
// inference omits omitempty/omitzero fields from "required", so rewrite the
// schema to mark all properties required, expressing optionality via nullable
// types instead. This mirrors .NET's
// AIJsonSchemaCreateOptions.RequireAllProperties = true.
makeStrict(schema)
return &Format{
ResponseFormat: agent.ResponseFormat{
Kind: "json",
Expand All @@ -40,6 +64,145 @@ func newFormat(name, description string, schema *jsonschema.Schema) *Format {
}
}

// walkSchema visits s and every subschema reachable from it, calling fn on each.
func walkSchema(s *jsonschema.Schema, fn func(*jsonschema.Schema)) {
if s == nil {
return
}
fn(s)
for _, sub := range s.Properties {
walkSchema(sub, fn)
}
walkSchema(s.Items, fn)
for _, sub := range s.PrefixItems {
walkSchema(sub, fn)
}
walkSchema(s.AdditionalProperties, fn)
for _, sub := range s.AnyOf {
walkSchema(sub, fn)
}
for _, sub := range s.AllOf {
walkSchema(sub, fn)
}
for _, sub := range s.OneOf {
walkSchema(sub, fn)
}
for _, def := range s.Defs {
walkSchema(def, fn)
}
for _, def := range s.Definitions {
walkSchema(def, fn)
}
}

// makeStrict recursively rewrites schema so that every object lists all of its
// properties in "required". Properties that were not originally required are
// made nullable so the model can still signal an absent value. This mirrors
// .NET's AIJsonSchemaCreateOptions.RequireAllProperties = true and produces a
// schema OpenAI accepts for strict structured outputs.
func makeStrict(s *jsonschema.Schema) {
walkSchema(s, func(s *jsonschema.Schema) {
if len(s.Properties) == 0 {
return
}
originalRequired := make(map[string]bool, len(s.Required))
for _, name := range s.Required {
originalRequired[name] = true
}
required := make([]string, 0, len(s.Properties))
seen := make(map[string]bool, len(s.Properties))
// Preserve the inferred property order when available.
for _, name := range s.PropertyOrder {
if _, ok := s.Properties[name]; ok && !seen[name] {
required = append(required, name)
seen[name] = true
}
}
rest := make([]string, 0, len(s.Properties))
for name := range s.Properties {
if !seen[name] {
rest = append(rest, name)
}
}
sort.Strings(rest)
required = append(required, rest...)
for _, name := range required {
if !originalRequired[name] {
makeNullable(s.Properties[name])
}
}
s.Required = required
})
}

// relaxStrict returns a deep copy of schema in which the strict-only additions
// made by makeStrict are undone for validation purposes: nullable (optional)
// properties are dropped from each object's "required" list. The input schema
// is left unchanged so the strict form is still sent to the provider.
func relaxStrict(schema *jsonschema.Schema) (*jsonschema.Schema, error) {
clone, err := cloneSchema(schema)
if err != nil {
return nil, err
}
walkSchema(clone, func(s *jsonschema.Schema) {
if len(s.Properties) == 0 || len(s.Required) == 0 {
return
}
required := s.Required[:0]
for _, name := range s.Required {
if prop := s.Properties[name]; prop != nil && isNullable(prop) {
continue
}
required = append(required, name)
}
s.Required = required
})
return clone, nil
}

// cloneSchema returns a deep copy of s via its JSON representation.
func cloneSchema(s *jsonschema.Schema) (*jsonschema.Schema, error) {
data, err := json.Marshal(s)
if err != nil {
return nil, fmt.Errorf("cloning schema: %w", err)
}
var out jsonschema.Schema
if err := json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("cloning schema: %w", err)
}
return &out, nil
}

// makeNullable marks s as accepting the JSON null value in addition to its
// existing type(s). In a strict schema every property stays required, so an
// originally optional property signals "no value" by being null rather than by
// being omitted.
func makeNullable(s *jsonschema.Schema) {
if s == nil || isNullable(s) {
return
}
switch {
case s.Type != "":
s.Types = []string{s.Type, "null"}
s.Type = ""
case len(s.Types) > 0:
s.Types = append(s.Types, "null")
}
}

// isNullable reports whether s permits the JSON null value.
func isNullable(s *jsonschema.Schema) bool {
if s.Type == "null" {
return true
}
for _, t := range s.Types {
if t == "null" {
return true
}
}
return false
}

// New creates a new JSON response format with the given name, description, and schema.
func New(name, description string, schema *jsonschema.Schema) agent.ResponseFormat {
return newFormat(name, description, schema).ResponseFormat
Expand Down
61 changes: 61 additions & 0 deletions agent/format/jsonformat/jsonformat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ package jsonformat_test

import (
"reflect"
"sort"
"testing"

"github.com/google/jsonschema-go/jsonschema"
"github.com/microsoft/agent-framework-go/agent"
"github.com/microsoft/agent-framework-go/agent/format/jsonformat"
)

Expand Down Expand Up @@ -42,6 +45,64 @@ func TestForType(t *testing.T) {
}
}

type Nested struct {
Inner Struct `json:"inner"`
Items []Struct `json:"items,omitempty"`
}

// objectSchema returns the object schema reached by descending through path (a
// sequence of property names), stepping into array item schemas along the way.
func objectSchema(t *testing.T, format agent.ResponseFormat, path ...string) *jsonschema.Schema {
t.Helper()
s, ok := format.Schema.(*jsonschema.Schema)
if !ok {
t.Fatalf("schema has type %T, want *jsonschema.Schema", format.Schema)
}
for _, name := range path {
next := s.Properties[name]
if next == nil {
t.Fatalf("property %q not found", name)
}
if next.Items != nil {
next = next.Items
}
s = next
}
return s
}

// TestStrictRequiresAllProperties verifies that, for OpenAI strict structured
// outputs, every key in an object's "properties" also appears in "required",
// including omitempty fields. Otherwise OpenAI rejects the schema with HTTP 400.
func TestStrictRequiresAllProperties(t *testing.T) {
format := jsonformat.MustFor[Nested]()
if !format.Strict {
t.Fatal("expected strict format")
}
check := func(name string, s *jsonschema.Schema) {
props := make([]string, 0, len(s.Properties))
for k := range s.Properties {
props = append(props, k)
}
req := append([]string(nil), s.Required...)
sort.Strings(props)
sort.Strings(req)
if len(props) != len(req) {
t.Fatalf("%s: required %v does not cover all properties %v", name, req, props)
}
for i := range props {
if props[i] != req[i] {
t.Fatalf("%s: required %v does not cover all properties %v", name, req, props)
}
}
}
// Root object and the nested "inner" object and array element must all
// require every property, including the omitempty "email" and "items".
check("root", objectSchema(t, format))
check("inner", objectSchema(t, format, "inner"))
check("items element", objectSchema(t, format, "items"))
}

func TestFormatKind(t *testing.T) {
format := jsonformat.MustFor[Struct]()
if format.Kind != "json" {
Expand Down