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
+198
View File
@@ -0,0 +1,198 @@
package run
import (
"encoding/json"
"fmt"
"io/fs"
"sort"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// result_schema.go — kernel-native structured output for single-loop runs.
//
// When tool.Invocation.ResultSchema is set, the executor validates the loop's
// final answer against the schema and, on violation, sends the loop back for
// bounded corrective rounds ON THE SAME CONVERSATION — the model repairs its
// own answer with full task context, which beats any post-hoc host-side
// reformat (the host can't know what the model meant). Mirrors the FinalGuard
// extra-round mechanics (same model, same observers, small step cap), with one
// deliberate contrast: FinalGuard discards the extra round's text, while a
// schema round's text IS the new candidate answer.
//
// Fail-open posture: a schema that doesn't compile logs a WARN and skips
// validation entirely — the host's pre-dispatch compile is the authoritative
// gate, and the kernel must not brick runs on a host bug. Persistent
// violations keep the last candidate output; hosts layer their own fallback
// validator/envelope on top.
const (
// resultSchemaMaxBytes caps the schema the kernel will compile.
resultSchemaMaxBytes = 64 << 10
// resultSchemaMaxRounds caps corrective rounds per run.
resultSchemaMaxRounds = 2
// resultSchemaRoundMaxSteps caps each corrective round. Producing a
// corrected JSON document needs no tools; 2 steps tolerates one stray
// tool call before the reply.
resultSchemaRoundMaxSteps = 2
// resultSchemaExtractCap bounds the output size scanned for a JSON
// document (the balanced-span scan is quadratic in the worst case).
resultSchemaExtractCap = 64 << 10
)
// denyLoader fails every $ref resolution. jsonschema/v6's default compiler
// resolves file:// refs from local disk (and can be configured for http) —
// a caller-supplied schema must never reach either.
type denyLoader struct{}
func (denyLoader) Load(url string) (any, error) {
return nil, fmt.Errorf("external $ref %q refused: remote/file schema loading is disabled: %w", url, fs.ErrNotExist)
}
// compileResultSchema compiles a raw schema with external loading disabled.
func compileResultSchema(raw json.RawMessage) (*jsonschema.Schema, error) {
if len(raw) > resultSchemaMaxBytes {
return nil, fmt.Errorf("schema is %d bytes (cap %d)", len(raw), resultSchemaMaxBytes)
}
doc, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
if err != nil {
return nil, fmt.Errorf("schema is not valid JSON: %w", err)
}
c := jsonschema.NewCompiler()
c.UseLoader(denyLoader{})
if err := c.AddResource("inline://result-schema", doc); err != nil {
return nil, err
}
return c.Compile("inline://result-schema")
}
// extractResultJSON finds the answer's JSON document: the whole string, a
// fenced block, or the first parseable balanced {...}/[...] span scanning
// left to right. Input is capped at resultSchemaExtractCap.
func extractResultJSON(s string) (json.RawMessage, bool) {
s = strings.TrimSpace(s)
if len(s) > resultSchemaExtractCap {
s = s[:resultSchemaExtractCap]
}
if json.Valid([]byte(s)) && len(s) > 0 {
return json.RawMessage(s), true
}
// Fenced block: ```json ... ``` or bare ``` ... ```.
for _, fence := range []string{"```json", "```"} {
if i := strings.Index(s, fence); i >= 0 {
rest := s[i+len(fence):]
if j := strings.Index(rest, "```"); j >= 0 {
candidate := strings.TrimSpace(rest[:j])
if json.Valid([]byte(candidate)) && candidate != "" {
return json.RawMessage(candidate), true
}
}
}
}
// First parseable balanced span.
for i := 0; i < len(s); i++ {
if s[i] != '{' && s[i] != '[' {
continue
}
if end, ok := balancedSpanEnd(s, i); ok {
candidate := s[i : end+1]
if json.Valid([]byte(candidate)) {
return json.RawMessage(candidate), true
}
}
}
return nil, false
}
// balancedSpanEnd returns the index of the bracket closing the span opened
// at start, honoring JSON string/escape rules.
func balancedSpanEnd(s string, start int) (int, bool) {
depth := 0
inStr := false
esc := false
for i := start; i < len(s); i++ {
c := s[i]
if inStr {
switch {
case esc:
esc = false
case c == '\\':
esc = true
case c == '"':
inStr = false
}
continue
}
switch c {
case '"':
inStr = true
case '{', '[':
depth++
case '}', ']':
depth--
if depth == 0 {
return i, true
}
}
}
return 0, false
}
// validateResultJSON returns flattened error paths, nil when the doc conforms.
func validateResultJSON(sch *jsonschema.Schema, doc json.RawMessage) []string {
inst, err := jsonschema.UnmarshalJSON(strings.NewReader(string(doc)))
if err != nil {
return []string{"document is not valid JSON: " + err.Error()}
}
err = sch.Validate(inst)
if err == nil {
return nil
}
var ve *jsonschema.ValidationError
if ok := asValidationError(err, &ve); !ok {
return []string{err.Error()}
}
var out []string
flattenValidationError(ve, &out)
sort.Strings(out)
if len(out) == 0 {
out = []string{ve.Error()}
}
return out
}
func asValidationError(err error, target **jsonschema.ValidationError) bool {
ve, ok := err.(*jsonschema.ValidationError)
if ok {
*target = ve
}
return ok
}
// flattenValidationError collects leaf causes as "/path: message" lines.
func flattenValidationError(ve *jsonschema.ValidationError, out *[]string) {
if len(ve.Causes) == 0 {
loc := "/" + strings.Join(ve.InstanceLocation, "/")
if len(ve.InstanceLocation) == 0 {
loc = "/"
}
*out = append(*out, fmt.Sprintf("%s: %v", loc, ve.ErrorKind))
return
}
for _, c := range ve.Causes {
flattenValidationError(c, out)
}
}
// resultSchemaCorrection builds the corrective user turn for one round.
func resultSchemaCorrection(raw json.RawMessage, errs []string) string {
return fmt.Sprintf(`Your final answer must be a SINGLE JSON document conforming to this JSON Schema (no prose, no code fences):
%s
Your previous answer failed validation:
- %s
Reply now with ONLY the corrected JSON document. Preserve your answer's content; do not invent data; use null or empty values for anything missing.`,
string(raw), strings.Join(errs, "\n- "))
}