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]>
199 lines
6.1 KiB
Go
199 lines
6.1 KiB
Go
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- "))
|
|
}
|