AI agents

Naming the First Failing Field: A Validator Wrapper

Antonio Evans8 min read

A close-up of a terminal window on a dark desk, showing a JSON document with one field highlighted in red and a line beneath it that reads items[2].price: expected number, got string.
Key points

Key points

  1. 1A validator wrapper should return the first failing path (for example items[2].price), the validator keyword, the expected value and the actual value, not just a message string.
  2. 2The underlying Python jsonschema library already exposes absolute_path, validator, validator_value, instance and json_path on every ValidationError, so the wrapper is mostly formatting work, not parsing.
  3. 3Feeding a structured error object back into the next prompt converts a blind retry (same prompt, same odds) into an edit request aimed at one field, which usually succeeds on the second attempt.
  4. 4You still need a plain retry underneath for empty or truncated output, because there is nothing to repair when the response has no parseable body.

The failure mode you are actually fixing

Ask a model for JSON and validate it, and the failure distribution is heavily skewed: most calls are fine, and a minority hand back something that does not parse or misses a required field. Three failure modes show up constantly once a pipeline is real: malformed parse, missing fields and ambiguous types such as a phone number returned as an integer that drops a leading zero the MLflow structured output guide.

The default response is to wrap the call in a retry. The problem is that a plain retry is the same prompt at the same temperature with roughly the same odds. You are paying for another round and hoping the dice land differently a practitioner writeup on feeding validation errors back into the retry. Retries also compound cost: a failure rate of 10 percent with two to three retries on average means paying 20 to 30 percent more for the same work, with end-to-end latency multiplied the Brightlume analysis of agent structured output failures.

That arithmetic is the argument for the wrapper. If a bad call out of every ten can be repaired by naming the one wrong field, the retry stops being a lottery ticket and becomes an edit.

What the validator already tells you

Before writing any code, know that you do not have to parse error strings. jsonschema's ValidationError carries the information in roughly three buckets: what happened (message), why it happened (validator, validator_value, schema_path), and what was being validated (instance, path, json_path) the official error-handling documentation.

AttributeTypeExample value
messagestring, human readable"'nine ninety-nine' is not of type 'number'"
validatorkeyword nametype
validator_valuethe schema keyword's value"number"
absolute_pathdeque of keys and indicesdeque(['items', 2, 'price'])
json_pathstring$.items[2].price
instancethe offending value"nine ninety-nine"
absolute_schema_pathdeque, relative to the root schemadeque(['properties', 'items', 'items', 'properties', 'price', 'type'])

The distinction between path and absolute_path matters when $ref is involved: absolute_schema_path is always relative to the original schema, not to whatever subschema was followed the jsonschema error reference. Use absolute_path for the data side and you will not care.

One trap: iterating errors and picking the first is not the same as picking the shallowest. Errors from iter_errors arrive in validation order, which is not sorted by how useful the message is. Sorting by path length, then by insertion order, gives a stable "first failing field" that reviewers can predict.

A sample schema and a malformed response

Take a small product catalog. The schema requires an array of items, each with an integer id, a non-empty name, and a non-negative price; the draft 2020-12 shape below follows the pattern used in a worked API validation tutorial the learn-api JSON Schema walkthrough.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["items"],
  "properties": {
    "items": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["id", "name", "price"],
        "properties": {
          "id": {"type": "integer", "minimum": 1},
          "name": {"type": "string", "minLength": 1},
          "price": {"type": "number", "minimum": 0}
        },
        "additionalProperties": false
      }
    }
  }
}

A plausible model response:

{
  "items": [
    {"id": 1, "name": "Kettle", "price": 29.0},
    {"id": 2, "name": "Mug", "price": 8.5},
    {"id": 3, "name": "Grinder", "price": "nine ninety-nine"}
  ]
}

Strictly, more than one error exists: "nine ninety-nine" fails the type check, and minimum also fails on a string for some validators. The wrapper's job is to report the one that a fix should target, which is the type failure at items[2].price, not a cascade of downstream complaints.

Building the wrapper

The wrapper is a thin function. It returns a discriminated result instead of raising, so the retry loop decides what to do. Everything below uses only jsonschema public attributes.

from dataclasses import dataclass, field, asdict
from typing import Any
from jsonschema import Draft202012Validator

@dataclass
class FieldError:
    path: str            # "$.items[2].price"
    segments: list       # ["items", 2, "price"]
    keyword: str         # "type"
    expected: Any        # "number"
    actual: Any          # "nine ninety-nine"
    message: str         # validator's own text, as a fallback

@dataclass
class Result:
    ok: bool
    data: Any = None
    error: FieldError | None = None

def to_json_path(segments) -> str:
    out = "$"
    for seg in segments:
        out += f"[{seg}]" if isinstance(seg, int) else f".{seg}"
    return out

def validate_first(schema: dict, instance: Any) -> Result:
    validator = Draft202012Validator(schema)
    errors = list(validator.iter_errors(instance))
    if not errors:
        return Result(ok=True, data=instance)
    errors.sort(key=lambda e: (len(e.absolute_path), e.absolute_path))
    e = errors[0]
    segments = list(e.absolute_path)
    return Result(ok=False, error=FieldError(
        path=to_json_path(segments),
        segments=segments,
        keyword=e.validator,
        expected=e.validator_value,
        actual=e.instance,
        message=e.message,
    ))

Two details earn their keep. The sort by path length means a missing top-level key is reported before a type mismatch three levels deep, which is usually the right repair order. And segments travels alongside the JSONPath string because the prompt text wants items[2].price while your logging and dashboards want the list form, so keep both.

Draft202012Validator is reused here because constructing a validator once and iterating many instances is the pattern the library documents; jsonschema.validate() is a convenience call that raises on the first failure and returns None on success, which is fine for one-off checks but not for a loop the learn-api tutorial.

For the sample above the wrapper produces:

{
  "path": "$.items[2].price",
  "segments": ["items", 2, "price"],
  "keyword": "type",
  "expected": "number",
  "actual": "nine ninety-nine",
  "message": "'nine ninety-nine' is not of type 'number'"
}

Deciding between repair, retry and fail

Not every failure deserves the same response, and this is the decision rule most write-ups skip. Classify by where the failure occurs and what the payload looks like.

SituationDetectResponse
Empty or truncated bodyJSON parse raises JSONDecodeErrorPlain retry; nothing to edit
Fenced or chatty outputParse fails but body contains {Strip, then one repair attempt
One named field wrongWrapper returns a FieldErrorFeedback retry naming that path
Missing required keykeyword == "required"Feedback retry naming the key
Same path fails twiceCounter per pathFail loudly; log and escalate
Enum or format violationkeyword in {enum, format}Feedback retry; enum values alone
Schema rejected at call timeProvider returns a schema errorDo not retry; fix the schema

The bottom row is the one that burns teams. Structured-output providers validate the schema itself, and their interpretation can tighten without warning; a schema that worked for weeks began failing validation with no client-side change, forcing a rewrite to satisfy undocumented validator behavior an OpenAI community report on a schema validator change. Treat schema rejection as a deployment bug, not a model bug, and do not spend retry budget on it.

There is also a prior question: should you be asking the model for structured output at all, versus tool calling? Structured outputs suit single-turn data transformation where the model has everything it needs and just needs to reshape it, while function calling suits multi-turn flows where the model picks a tool and your code executes it the Machine Learning Mastery comparison. If the task is extraction, the wrapper belongs on a structured-output call. If the task is routing to a tool, validate the arguments with the same wrapper but expect a different retry shape.

Wiring the retry loop

The loop has one job: on failure, send back the model's own output plus a description of the wrong field, and ask it to fix that field. Describing the error for the model, not for a log, is the detail that does the work; the follow-up prompt should read like "field X must be a number, you sent a string", and the prior payload should ride along so the model edits rather than regenerates the dev.to writeup on error feedback retries.

def feedback_message(err: FieldError, prior_json: str) -> str:
    return (
        "Your previous response failed validation at one field.\n"
        f"Path: {err.path}\n"
        f"Rule: {err.keyword}\n"
        f"Expected: {err.expected!r}\n"
        f"Received: {err.actual!r}\n"
        "Return the full corrected JSON object. Change only that field.\n"
        f"Previous response:\n{prior_json}"
    )

def generate_validated(generate, schema, prompt, max_attempts=3):
    messages = [{"role": "user", "content": prompt}]
    last_error = None
    for attempt in range(1, max_attempts + 1):
        raw = generate(messages)              # your model call
        try:
            parsed = json.loads(raw)
        except json.JSONDecodeError:
            last_error = "unparseable"
            messages = messages[:1]           # plain retry
            continue
        result = validate_first(schema, parsed)
        if result.ok:
            return parsed, attempt
        last_error = result.error
        messages = [{"role": "user", "content": prompt},
                    {"role": "assistant", "content": raw},
                    {"role": "user", "content": feedback_message(result.error, raw)}]
    raise RuntimeError(f"validation failed after {max_attempts}: {last_error}")

Two operational notes. The follow-up prompt is longer, since it carries the error plus the prior payload, so cap attempts and track the per-path failure counter; a path that fails twice is a schema or prompt design problem, not a sampling problem. And if you fail over between providers mid-loop, do not count the provider swap as a real attempt, because prompt semantics do not always transfer the same writeup.

Worked numbers: what feedback buys you

Suppose 1,000 calls, a 12 percent first-pass validation failure rate, and a per-request cost of one unit. Under blind retry with an optimistic 60 percent success per retry: 120 failures produce 48 residual failures after one retry, and roughly 19 after two. You also pay 120 extra calls, a 12 percent cost overhead.

Under feedback retry, the same 120 failures split by the table above. Truncated or unparseable bodies (say a third, 40 calls) go to plain retry; the rest (80 calls) get a named field. If feedback converts 85 percent of those to a pass on the next call, residual failures drop to 12 plus whatever the truncated group leaves, and the following call count is the same 120, but aimed. The saving is not calls, it is double retries and escalations. If you also shrink the schema to fewer required fields, you shrink the base failure rate directly; keeping schemas minimal with required fields, strict types and enums is the cheapest reliability lever available the MLflow guide.

Worth remembering that constrained decoding changes the mix. Grammar-based approaches filter invalid tokens during generation, so syntax errors shrink and the wrapper's findings concentrate on semantic problems like wrong values and wrong enum members; constrained decoding guarantees the output is syntactically valid but does not ensure the values are correct the MLflow guide. That means your feedback prompts should name values, not just types, as context counts shift.

When you generate the same schema across several model families, running one code path against every vendor keeps the retry loop honest. Quanta Inference exposes text, image, video, audio and embedding models behind one OpenAI-compatible API key, so generate(messages) above stays a single function while you compare validation failure rates per model. If the failure is really a schema design problem, a Quanta expert can take the schema, the failing payloads and the retry logs and hand back a tightened contract: Quanta Experts.

Handling the edges: repair, truncation and unrecoverable states

Malformed JSON is not always a model mistake, and you should not silently repair your way past a broken pipeline. Repair libraries exist and are useful: json_repair parses otherwise invalid JSON and tries to recover the missing values the json_repair repository. The key discipline is ordering. A three-stage pipeline of syntactic repair, fallback extraction, then schema validation is a reasonable shape, but the comment that prompted it is the real lesson: an LLM response is untrusted input, and the worst version of the failure is not the crash, it is the half-parsed object that flows downstream and corrupts something three steps later the dev.to comment thread on truncated responses.

Concretely: never let a repaired object skip the wrapper. Repair gets you something to validate; validation decides whether it enters your system. And make truncation a loud, handled case with its own counter, because a truncated response and a wrong field need different retries.

Two more edges are worth naming. If your schema uses additionalProperties: false and the model invents a field, the error path points at the parent object, not the invented key; the unexpected names live inside the message text, and extracting them means either parsing or a manual set difference between instance keys and schema properties, which is the workaround the jsonschema issue thread settled on the jsonschema issue on removing failing fields. Decide once whether invented fields are fatal or stripped, and encode that in the classifier rather than in the wrapper.

If you want human-friendly messages, the supported route is adding your own annotation to the schema and looking it up on the error, for example reading error.schema.get("error_msg", error.message), rather than regexing the default text the Stack Overflow thread on custom jsonschema messages. That keeps the wrapper dumb and the wording declarative.

Finally, observability. Log the raw output, the parsed object and the structured error for every failure so you can count failures per path and per model over time; the recommended pattern is exactly that, raw and parsed output logging with validation failure metrics, so regressions show up before users find them the MLflow guide. A wrapper that reports items[2].price is useful in a log; a dashboard of the top failing paths is what stops the next incident.

FAQ

FAQ

Why report only the first failing field instead of all errors?

Because the retry prompt is more effective when it asks for one concrete edit, and because cascading errors on the same value (a string failing both type and minimum) confuse the model about what to change. Collect all errors for logging and dashboards, but select the shallowest, then the earliest, for the feedback message.

What if the model returns JSON wrapped in markdown fences?

That is a parse failure, not a validation failure, so it takes the repair branch. Strip the fences, parse, then run the wrapper. If the wrapper passes, you are done; do not send a feedback prompt, because there is no field to name.

Does this work with providers that enforce a schema server-side?

Yes, as a second gate. Schema enforcement guarantees syntax and shape but not value correctness, and providers can also reject the schema itself at call time, which is a config error to fix rather than retry the MLflow guide an OpenAI community report.

How many retries should the loop allow?

Three attempts total is a common stopping point, since the second attempt is the feedback edit and the third is a last chance with a lower temperature or a different model. Track failures per path; anything that survives two feedback prompts is a prompt or schema defect.

Can I skip the wrapper if I already use a validation library?

The library gives you the raw attributes; the wrapper gives you a stable contract (path, keyword, expected, actual) that your prompt template, logs and dashboards all consume. That contract is what makes the retry loop portable across models and providers, and it is the part you will change least over time.

Sources

Sources

  1. Enforce, Validate, Observe: 3 LLM Structured Output Patterns ... - MLflow
  2. When an LLM response fails validation, feed the error back ...
  3. Why Most AI Agents Fail at Structured Output and How to ...
  4. Handling Validation Errors - Python jsonschema
  5. JSON Schema Validation in Python: Check API Responses
  6. Structured Output Schema Validation Changed Without Notice
  7. Structured Outputs vs. Function Calling: Which Should Your Agent ...
  8. mangiucugna/json_repair: Repair malformed JSON from ...
  9. Truncated LLM responses breaking JSON.parse is one ...
  10. Removing failing fields from JSON? · Issue #448
  11. jsonschema package to validate schema and custom error ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles