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,89 @@
|
||||
package majordomo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
type verdict struct {
|
||||
Guilty bool `json:"guilty"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
func TestGenerateTyped(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
fp := fake.New("fp")
|
||||
r.RegisterProvider(fp)
|
||||
fp.Enqueue("judge", fake.Reply(`{"guilty":true,"why":"caught red-handed"}`))
|
||||
|
||||
m, _ := r.Parse("fp/judge")
|
||||
v, err := Generate[verdict](context.Background(), m, Request{Messages: []Message{UserText("verdict?")}})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !v.Guilty || v.Why != "caught red-handed" {
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
|
||||
// The schema derived from the struct reached the provider, named after
|
||||
// the type.
|
||||
req := fp.Calls()[0].Request
|
||||
if req.SchemaName != "verdict" {
|
||||
t.Errorf("schema name = %q", req.SchemaName)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(req.Schema, &schema); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
props := schema["properties"].(map[string]any)
|
||||
if _, ok := props["guilty"]; !ok {
|
||||
t.Errorf("schema = %v", schema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStripsMarkdownFence(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
fp := fake.New("fp")
|
||||
r.RegisterProvider(fp)
|
||||
fp.Enqueue("judge", fake.Reply("```json\n{\"guilty\":false,\"why\":\"alibi\"}\n```"))
|
||||
|
||||
m, _ := r.Parse("fp/judge")
|
||||
v, err := Generate[verdict](context.Background(), m, Request{Messages: []Message{UserText("verdict?")}})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if v.Guilty || v.Why != "alibi" {
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDecodeErrorNamesModel(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
fp := fake.New("fp")
|
||||
r.RegisterProvider(fp)
|
||||
fp.Enqueue("judge", fake.Reply("not json at all"))
|
||||
|
||||
m, _ := r.Parse("fp/judge")
|
||||
_, err := Generate[verdict](context.Background(), m, Request{Messages: []Message{UserText("verdict?")}})
|
||||
if err == nil || !strings.Contains(err.Error(), "fp/judge") {
|
||||
t.Errorf("err = %v, want decode error naming the model", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateEmptyResponse(t *testing.T) {
|
||||
r := newTestRegistry(t)
|
||||
fp := fake.New("fp")
|
||||
r.RegisterProvider(fp)
|
||||
fp.Enqueue("judge", fake.ReplyWith(llm.Response{FinishReason: llm.FinishLength}))
|
||||
|
||||
m, _ := r.Parse("fp/judge")
|
||||
_, err := Generate[verdict](context.Background(), m, Request{Messages: []Message{UserText("verdict?")}})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("err = %v, want empty-response error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user