feat: agent run loop, Generate[T], reflect-derived schemas
Phase 5: - agent/: model + system prompt + toolboxes composition; bounded tool-dispatch loop (default 10 steps); panic-proof tool execution; unknown-tool and duplicate-name handling; history continuation; step observers; partial results on ErrMaxSteps/errors (ADR-0012) - llm.SchemaFor[T]: strict-compatible JSON schemas from Go types (nullable pointers, description/enum tags, recursion rejected) - majordomo.Generate[T]: typed structured output with fence-stripping decode and model-naming errors - README agents/structured-output sections + matrix synced Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SchemaFor derives a JSON Schema from Go type T for structured output.
|
||||
//
|
||||
// The emitted schema is strict-mode-compatible across providers: every
|
||||
// object lists all its properties as required and sets
|
||||
// additionalProperties:false; optionality is expressed by pointer fields,
|
||||
// which become nullable (anyOf with null) and may simply be returned as
|
||||
// null by the model.
|
||||
//
|
||||
// Field tags: `json:"name"` / `json:"-"` control naming and omission;
|
||||
// `description:"..."` documents a field for the model; `enum:"a,b,c"`
|
||||
// constrains a string field to fixed values.
|
||||
//
|
||||
// Supported kinds: strings, bools, integer and float kinds, structs
|
||||
// (nested), slices/arrays, map[string]V, pointers (nullable), time.Time
|
||||
// (string, date-time), json.RawMessage and interface{} (any). Recursive
|
||||
// types are rejected — no provider's structured-output mode accepts them.
|
||||
func SchemaFor[T any]() (json.RawMessage, error) {
|
||||
t := reflect.TypeFor[T]()
|
||||
schema, err := schemaOf(t, "", "", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: SchemaFor[%s]: %w", t, err)
|
||||
}
|
||||
out, err := json.Marshal(schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm: SchemaFor[%s]: encode: %w", t, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
timeType = reflect.TypeFor[time.Time]()
|
||||
rawType = reflect.TypeFor[json.RawMessage]()
|
||||
)
|
||||
|
||||
// schemaOf builds the schema map for one type. visiting tracks the struct
|
||||
// types on the current path for cycle detection.
|
||||
func schemaOf(t reflect.Type, description, enum string, visiting []reflect.Type) (map[string]any, error) {
|
||||
s := make(map[string]any)
|
||||
if description != "" {
|
||||
s["description"] = description
|
||||
}
|
||||
|
||||
switch {
|
||||
case t == timeType:
|
||||
s["type"] = "string"
|
||||
s["format"] = "date-time"
|
||||
return s, nil
|
||||
case t == rawType:
|
||||
// Any JSON value.
|
||||
return s, nil
|
||||
}
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Pointer:
|
||||
inner, err := schemaOf(t.Elem(), "", enum, visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s["anyOf"] = []any{inner, map[string]any{"type": "null"}}
|
||||
return s, nil
|
||||
|
||||
case reflect.String:
|
||||
s["type"] = "string"
|
||||
if enum != "" {
|
||||
var vals []any
|
||||
for v := range strings.SplitSeq(enum, ",") {
|
||||
vals = append(vals, strings.TrimSpace(v))
|
||||
}
|
||||
s["enum"] = vals
|
||||
}
|
||||
return s, nil
|
||||
|
||||
case reflect.Bool:
|
||||
s["type"] = "boolean"
|
||||
return s, nil
|
||||
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
s["type"] = "integer"
|
||||
return s, nil
|
||||
|
||||
case reflect.Float32, reflect.Float64:
|
||||
s["type"] = "number"
|
||||
return s, nil
|
||||
|
||||
case reflect.Slice, reflect.Array:
|
||||
if t.Elem().Kind() == reflect.Uint8 {
|
||||
// []byte: base64 text on the wire.
|
||||
s["type"] = "string"
|
||||
return s, nil
|
||||
}
|
||||
items, err := schemaOf(t.Elem(), "", "", visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s["type"] = "array"
|
||||
s["items"] = items
|
||||
return s, nil
|
||||
|
||||
case reflect.Map:
|
||||
if t.Key().Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("map key type %s unsupported (only string keys)", t.Key())
|
||||
}
|
||||
values, err := schemaOf(t.Elem(), "", "", visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s["type"] = "object"
|
||||
s["additionalProperties"] = values
|
||||
return s, nil
|
||||
|
||||
case reflect.Interface:
|
||||
// Any JSON value.
|
||||
return s, nil
|
||||
|
||||
case reflect.Struct:
|
||||
if slices.Contains(visiting, t) {
|
||||
return nil, fmt.Errorf("recursive type %s (structured-output schemas cannot recurse)", t)
|
||||
}
|
||||
visiting = append(visiting, t)
|
||||
|
||||
properties := make(map[string]any)
|
||||
required := []string{}
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
if !f.IsExported() {
|
||||
continue
|
||||
}
|
||||
name := f.Name
|
||||
if tag, ok := f.Tag.Lookup("json"); ok {
|
||||
base, _, _ := strings.Cut(tag, ",")
|
||||
if base == "-" {
|
||||
continue
|
||||
}
|
||||
if base != "" {
|
||||
name = base
|
||||
}
|
||||
}
|
||||
fs, err := schemaOf(f.Type, f.Tag.Get("description"), f.Tag.Get("enum"), visiting)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %s: %w", f.Name, err)
|
||||
}
|
||||
properties[name] = fs
|
||||
required = append(required, name)
|
||||
}
|
||||
s["type"] = "object"
|
||||
s["properties"] = properties
|
||||
s["required"] = required
|
||||
s["additionalProperties"] = false
|
||||
return s, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("kind %s unsupported in structured-output schemas", t.Kind())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func mustSchema[T any](t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := SchemaFor[T]()
|
||||
if err != nil {
|
||||
t.Fatalf("SchemaFor: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("schema is not valid JSON: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestSchemaForBasicStruct(t *testing.T) {
|
||||
type Person struct {
|
||||
Name string `json:"name" description:"full name"`
|
||||
Age int `json:"age"`
|
||||
Score float64 `json:"score"`
|
||||
Active bool `json:"active"`
|
||||
Ignored string `json:"-"`
|
||||
hidden string //nolint:unused
|
||||
}
|
||||
s := mustSchema[Person](t)
|
||||
if s["type"] != "object" || s["additionalProperties"] != false {
|
||||
t.Errorf("root = %v", s)
|
||||
}
|
||||
props := s["properties"].(map[string]any)
|
||||
if len(props) != 4 {
|
||||
t.Errorf("properties = %v", props)
|
||||
}
|
||||
if props["name"].(map[string]any)["description"] != "full name" {
|
||||
t.Errorf("name = %v", props["name"])
|
||||
}
|
||||
if props["age"].(map[string]any)["type"] != "integer" ||
|
||||
props["score"].(map[string]any)["type"] != "number" ||
|
||||
props["active"].(map[string]any)["type"] != "boolean" {
|
||||
t.Errorf("props = %v", props)
|
||||
}
|
||||
req := s["required"].([]any)
|
||||
if len(req) != 4 {
|
||||
t.Errorf("required = %v (all fields must be required for strict mode)", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaForNestedAndCollections(t *testing.T) {
|
||||
type Inner struct {
|
||||
V string `json:"v"`
|
||||
}
|
||||
type Outer struct {
|
||||
Items []Inner `json:"items"`
|
||||
Labels map[string]string `json:"labels"`
|
||||
Blob []byte `json:"blob"`
|
||||
When time.Time `json:"when"`
|
||||
Extra json.RawMessage `json:"extra"`
|
||||
}
|
||||
s := mustSchema[Outer](t)
|
||||
props := s["properties"].(map[string]any)
|
||||
|
||||
items := props["items"].(map[string]any)
|
||||
if items["type"] != "array" {
|
||||
t.Errorf("items = %v", items)
|
||||
}
|
||||
inner := items["items"].(map[string]any)
|
||||
if inner["type"] != "object" || inner["additionalProperties"] != false {
|
||||
t.Errorf("inner = %v", inner)
|
||||
}
|
||||
labels := props["labels"].(map[string]any)
|
||||
if labels["type"] != "object" || labels["additionalProperties"].(map[string]any)["type"] != "string" {
|
||||
t.Errorf("labels = %v", labels)
|
||||
}
|
||||
if props["blob"].(map[string]any)["type"] != "string" {
|
||||
t.Errorf("blob = %v", props["blob"])
|
||||
}
|
||||
when := props["when"].(map[string]any)
|
||||
if when["type"] != "string" || when["format"] != "date-time" {
|
||||
t.Errorf("when = %v", when)
|
||||
}
|
||||
if len(props["extra"].(map[string]any)) != 0 {
|
||||
t.Errorf("extra (RawMessage) should be the any-schema, got %v", props["extra"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaForPointerIsNullable(t *testing.T) {
|
||||
type Opt struct {
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
s := mustSchema[Opt](t)
|
||||
note := s["properties"].(map[string]any)["note"].(map[string]any)
|
||||
anyOf := note["anyOf"].([]any)
|
||||
if len(anyOf) != 2 {
|
||||
t.Fatalf("anyOf = %v", anyOf)
|
||||
}
|
||||
if anyOf[0].(map[string]any)["type"] != "string" || anyOf[1].(map[string]any)["type"] != "null" {
|
||||
t.Errorf("anyOf = %v", anyOf)
|
||||
}
|
||||
// Still required (strict-mode style: optional == nullable).
|
||||
if req := s["required"].([]any); len(req) != 1 {
|
||||
t.Errorf("required = %v", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaForEnum(t *testing.T) {
|
||||
type Choice struct {
|
||||
Mood string `json:"mood" enum:"happy,sad, ambivalent"`
|
||||
}
|
||||
s := mustSchema[Choice](t)
|
||||
mood := s["properties"].(map[string]any)["mood"].(map[string]any)
|
||||
enum := mood["enum"].([]any)
|
||||
if len(enum) != 3 || enum[2] != "ambivalent" {
|
||||
t.Errorf("enum = %v (values must be trimmed)", enum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaForRecursionRejected(t *testing.T) {
|
||||
type Node struct {
|
||||
Children []*Node `json:"children"`
|
||||
}
|
||||
_, err := SchemaFor[Node]()
|
||||
if err == nil || !strings.Contains(err.Error(), "recursive") {
|
||||
t.Errorf("err = %v, want recursion rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaForUnsupportedKind(t *testing.T) {
|
||||
type Bad struct {
|
||||
Ch chan int `json:"ch"`
|
||||
}
|
||||
if _, err := SchemaFor[Bad](); err == nil {
|
||||
t.Error("chan field must be rejected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user