The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
236 lines
5.7 KiB
Go
236 lines
5.7 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// 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)
|
|
}
|
|
}
|