Normalize required fields in agent JSON schemas for OpenAI

OpenAI rejects schemas where optional properties are absent from the
required array. Promote all properties to required and mark formerly
optional ones nullable so the model knows it may pass null.

Also upgrade tool error log level from Warn to Error.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-06-22 08:40:10 +02:00
parent 982509416e
commit e2219c9d1a
4 changed files with 163 additions and 9 deletions

View File

@@ -18,6 +18,7 @@ import (
"encoding/json"
"fmt"
"reflect"
"slices"
"github.com/google/jsonschema-go/jsonschema"
)
@@ -37,6 +38,14 @@ func jsonSchemaFor[T any]() (json.RawMessage, error) {
return nil, fmt.Errorf("cannot marshal schema for %s: %w", t, err)
}
// OpenAI rejects schemas where required does not list every key in
// properties. Promote optional properties into required and mark them
// nullable so the model knows it may pass null.
data, err = normalizeRequiredJSON(data)
if err != nil {
return nil, fmt.Errorf("cannot normalize schema for %s: %w", t, err)
}
return json.RawMessage(data), nil
}
@@ -49,6 +58,122 @@ func mustJSONSchemaFor[T any]() json.RawMessage {
return schema
}
// normalizeRequiredJSON ensures every property key in an object schema also
// appears in its required array. Properties that were not originally required
// are made nullable (their "type" becomes ["T","null"]) so the LLM knows it
// may pass null for them. The transformation is applied recursively so nested
// object schemas are also normalised.
func normalizeRequiredJSON(data []byte) ([]byte, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
return data, nil
}
propsRaw, hasProps := obj["properties"]
if !hasProps {
if itemsRaw, ok := obj["items"]; ok {
n, err := normalizeRequiredJSON(itemsRaw)
if err != nil {
return nil, err
}
obj["items"] = n
}
if addlRaw, ok := obj["additionalProperties"]; ok {
n, err := normalizeRequiredJSON(addlRaw)
if err != nil {
return nil, err
}
obj["additionalProperties"] = n
}
return json.Marshal(obj)
}
var props map[string]json.RawMessage
if err := json.Unmarshal(propsRaw, &props); err != nil {
return data, nil
}
var required []string
if reqRaw, ok := obj["required"]; ok {
_ = json.Unmarshal(reqRaw, &required)
}
requiredSet := make(map[string]bool, len(required))
for _, r := range required {
requiredSet[r] = true
}
for name, propRaw := range props {
n, err := normalizeRequiredJSON(propRaw)
if err != nil {
return nil, err
}
if !requiredSet[name] {
n, err = makeNullableJSON(n)
if err != nil {
return nil, err
}
required = append(required, name)
requiredSet[name] = true
}
props[name] = n
}
propsData, err := json.Marshal(props)
if err != nil {
return nil, err
}
obj["properties"] = propsData
if len(required) > 0 {
reqData, err := json.Marshal(required)
if err != nil {
return nil, err
}
obj["required"] = reqData
}
return json.Marshal(obj)
}
// makeNullableJSON adds "null" to the "type" field of a JSON Schema object so
// that the LLM understands it may pass null for optional properties.
func makeNullableJSON(data []byte) ([]byte, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(data, &obj); err != nil {
return data, nil
}
typeRaw, ok := obj["type"]
if !ok {
return data, nil
}
var single string
if err := json.Unmarshal(typeRaw, &single); err == nil {
if single != "null" {
arr, _ := json.Marshal([]string{single, "null"})
obj["type"] = arr
}
return json.Marshal(obj)
}
var arr []string
if err := json.Unmarshal(typeRaw, &arr); err == nil {
if slices.Contains(arr, "null") {
return data, nil
}
arr = append(arr, "null")
nullable, _ := json.Marshal(arr)
obj["type"] = nullable
return json.Marshal(obj)
}
return data, nil
}
// stripNullTypes removes "null" from union types produced by pointer fields
// (e.g. ["null","string"] becomes "string") and clears integer bounds so that
// LLM providers receive a clean schema without Go-specific type constraints.