7dab4112ff
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>
141 lines
3.8 KiB
Go
141 lines
3.8 KiB
Go
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")
|
|
}
|
|
}
|