feat(run): kernel-native result_schema — validate + in-loop self-repair
executus CI / test (pull_request) Failing after 38s
Gadfly review (reusable) / review (pull_request) Successful in 11m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m34s

tool.Invocation.ResultSchema (JSON Schema, draft 2020-12): the executor
validates the single-loop final answer and, on violation, runs up to two
bounded corrective rounds on the same conversation (FinalGuard-style
extra rounds, but the round's text IS the new candidate); a conforming
answer is replaced by the bare validated JSON. Fail-open on compile
problems (host pre-dispatch compile is the authoritative gate) and
best-effort on persistent violations (last output stands, hosts keep
their fallback validator). External $refs fail closed — jsonschema/v6's
default loader resolves file:// from local disk, overridden with a deny
loader. Audit events: result_schema_round/_valid/_unmet/_skipped.
Multi-phase runs unvalidated (FinalGuard's single-loop-only scope).

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-17 13:17:43 -04:00
co-authored by Claude Fable 5
parent 46e4eb3da6
commit b4080a8a6e
6 changed files with 496 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
package run
import (
"context"
"encoding/json"
"strings"
"testing"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
func resultSchemaExecutor(t *testing.T, m llm.Model, rec RunRecorder) *Executor {
t.Helper()
ports := Ports{}
if rec != nil {
ports.Audit = auditFunc{start: func(RunInfo) RunRecorder { return rec }}
}
return New(Config{
Registry: tool.NewRegistry(),
Models: func(ctx context.Context, _ string) (context.Context, llm.Model, error) { return ctx, m, nil },
Ports: ports,
})
}
var personSchema = json.RawMessage(`{"type":"object","required":["name"],"properties":{"name":{"type":"string"}},"additionalProperties":false}`)
// TestResultSchema_ConformingAnswerPassesThrough — a first-try conforming
// answer (fence-wrapped, even) is replaced by the bare JSON with no extra
// model rounds.
func TestResultSchema_ConformingAnswerPassesThrough(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("Here you go:\n```json\n{\"name\": \"mort\"}\n```"))
m, _ := fp.Model("m")
ex := resultSchemaExecutor(t, m, nil)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs1", CallerID: "c", ResultSchema: personSchema}, "who are you")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != `{"name": "mort"}` {
t.Fatalf("output = %q, want the bare validated JSON", res.Output)
}
if n := fp.CallCount("m"); n != 1 {
t.Fatalf("model calls = %d, want 1 (no corrective round)", n)
}
}
// TestResultSchema_CorrectiveRoundRepairs — a non-conforming first answer
// triggers one corrective round whose text becomes the run output.
func TestResultSchema_CorrectiveRoundRepairs(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply(`{"name": 42}`), // wrong type
fake.Reply(`{"name": "fixed"}`), // corrective round conforms
)
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs2", CallerID: "c", ResultSchema: personSchema}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != `{"name": "fixed"}` {
t.Fatalf("output = %q, want the corrected JSON", res.Output)
}
if n := fp.CallCount("m"); n != 2 {
t.Fatalf("model calls = %d, want 2", n)
}
if !recHas(rec, "result_schema_round") || !recHas(rec, "result_schema_valid") {
t.Fatalf("expected round + valid audit events, got %v", rec.events)
}
}
// TestResultSchema_PersistentViolationKeepsLastOutput — rounds exhaust, the
// last candidate stands, and result_schema_unmet is recorded (the host's own
// validator is the fallback authority).
func TestResultSchema_PersistentViolationKeepsLastOutput(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m",
fake.Reply("not json at all"),
fake.Reply("still prose"),
fake.Reply("prose forever"),
)
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs3", CallerID: "c", ResultSchema: personSchema}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "prose forever" {
t.Fatalf("output = %q, want the last candidate kept", res.Output)
}
if n := fp.CallCount("m"); n != 1+resultSchemaMaxRounds {
t.Fatalf("model calls = %d, want %d", n, 1+resultSchemaMaxRounds)
}
if !recHas(rec, "result_schema_unmet") {
t.Fatalf("expected result_schema_unmet, got %v", rec.events)
}
}
// TestResultSchema_NoSchemaUnchanged — anchor: without a schema the output
// and call count are untouched.
func TestResultSchema_NoSchemaUnchanged(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("plain prose answer"))
m, _ := fp.Model("m")
ex := resultSchemaExecutor(t, m, nil)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs4", CallerID: "c"}, "go")
if res.Err != nil || res.Output != "plain prose answer" {
t.Fatalf("output = %q err = %v", res.Output, res.Err)
}
if n := fp.CallCount("m"); n != 1 {
t.Fatalf("model calls = %d, want 1", n)
}
}
// TestResultSchema_CompileFailureFailsOpen — an uncompilable schema skips
// validation (WARN + audit event), never bricks the run.
func TestResultSchema_CompileFailureFailsOpen(t *testing.T) {
fp := fake.New("fake")
fp.Enqueue("m", fake.Reply("prose"))
m, _ := fp.Model("m")
rec := &eventRecorder{}
ex := resultSchemaExecutor(t, m, rec)
res := ex.Run(context.Background(),
RunnableAgent{Name: "g", ModelTier: "m"},
tool.Invocation{RunID: "r-rs5", CallerID: "c",
ResultSchema: json.RawMessage(`{"type": "not-a-type"}`)}, "go")
if res.Err != nil {
t.Fatalf("run error: %v", res.Err)
}
if res.Output != "prose" {
t.Fatalf("output = %q, want untouched", res.Output)
}
if !recHas(rec, "result_schema_skipped") {
t.Fatalf("expected result_schema_skipped, got %v", rec.events)
}
}
// TestCompileResultSchema_ExternalRefsFailClosed — http(s) AND file $refs
// must not resolve (jsonschema/v6's default loader reads local disk).
func TestCompileResultSchema_ExternalRefsFailClosed(t *testing.T) {
for _, ref := range []string{
"https://example.com/schema.json",
"file:///etc/passwd",
} {
raw := json.RawMessage(`{"$ref": "` + ref + `"}`)
if _, err := compileResultSchema(raw); err == nil {
t.Errorf("$ref %q compiled — external loading must fail closed", ref)
} else if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "not exist") {
// Any error is acceptable as long as it FAILS; the refused
// wording just confirms the deny loader was consulted.
t.Logf("$ref %q failed with: %v", ref, err)
}
}
}
// TestExtractResultJSON_Shapes — extraction tolerance.
func TestExtractResultJSON_Shapes(t *testing.T) {
cases := []struct {
in string
want string
ok bool
}{
{`{"a":1}`, `{"a":1}`, true},
{"prefix text {\"a\": {\"b\": \"}\"}} suffix", `{"a": {"b": "}"}}`, true},
{"```json\n[1,2]\n```", `[1,2]`, true},
{"no json here", "", false},
{"broken { \"a\": ", "", false},
}
for _, c := range cases {
got, ok := extractResultJSON(c.in)
if ok != c.ok || (ok && string(got) != c.want) {
t.Errorf("extract(%q) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.ok)
}
}
}
// recHas reports whether the recorder saw an event type.
func recHas(r *eventRecorder, name string) bool {
for _, e := range r.events {
if e == name {
return true
}
}
return false
}