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:
2026-06-10 13:10:18 +02:00
parent 1ca607906d
commit 7dab4112ff
10 changed files with 1211 additions and 7 deletions
+166
View File
@@ -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())
}
}
+140
View File
@@ -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")
}
}