Files
probo/pkg/agent/schema.go
Bryan Frimin e2219c9d1a 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>
2026-06-22 11:38:50 +02:00

220 lines
5.3 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package agent
import (
"encoding/json"
"fmt"
"reflect"
"slices"
"github.com/google/jsonschema-go/jsonschema"
)
func jsonSchemaFor[T any]() (json.RawMessage, error) {
t := reflect.TypeFor[T]()
schema, err := jsonschema.ForType(t, nil)
if err != nil {
return nil, fmt.Errorf("cannot generate schema for %s: %w", t, err)
}
stripNullTypes(schema)
data, err := json.Marshal(schema)
if err != nil {
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
}
func mustJSONSchemaFor[T any]() json.RawMessage {
schema, err := jsonSchemaFor[T]()
if err != nil {
panic(err)
}
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.
func stripNullTypes(s *jsonschema.Schema) {
if s == nil {
return
}
if len(s.Types) > 0 {
filtered := make([]string, 0, len(s.Types))
for _, t := range s.Types {
if t != "null" {
filtered = append(filtered, t)
}
}
if len(filtered) == 1 {
s.Type = filtered[0]
s.Types = nil
} else if len(filtered) > 1 {
s.Types = filtered
}
}
s.Minimum = nil
s.Maximum = nil
if s.Type == "object" && s.Properties == nil {
s.Properties = make(map[string]*jsonschema.Schema)
}
for _, prop := range s.Properties {
stripNullTypes(prop)
}
if s.Items != nil {
stripNullTypes(s.Items)
}
if s.AdditionalProperties != nil {
stripNullTypes(s.AdditionalProperties)
}
}