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>
90 lines
2.5 KiB
Go
90 lines
2.5 KiB
Go
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)
|
|
}
|
|
}
|