Validation Retry
Automatically retry structured output that fails Zod validation, feeding errors back to the model for self-correction.
When using structured output (Zod schemas), models sometimes return invalid JSON or values that don't match your schema. validationRetry implements the Instructor pattern: it feeds the validation error back to the model so it can fix its own output.
const result = await adapter.generate(prompt, {
model: "claude-sonnet-4-20250514",
input: { query: "Extract user info from this text" },
validationRetry: { maxRetries: 3 },
});If the model returns {"name": "Alice", "age": "thirty"} but the schema expects age: z.number(), Crux injects the Zod error as a corrective message and the model retries with {"name": "Alice", "age": 30}.
When do you need this?
- Structured output reliability: when models occasionally return malformed JSON or wrong types
- Complex schemas: enums, nested objects, and strict constraints that models sometimes violate
- Production reliability: automatic recovery without manual error handling
Structured output is always validated, with or without validationRetry.
The option controls only whether Crux asks the model to try again. Without it,
output that fails your schema throws ValidationExhaustedError — it is never
returned unvalidated.
If your prompts always produce valid output or you don't use output schemas, you don't need validationRetry.
How it works
Validation retry follows a 3-tier approach, from cheapest to most expensive:
Tier 1: Text repair (zero cost)
repairJsonText() fixes common LLM output issues without making another API call:
- Strips markdown code fences (
```json ... ```) - Removes preamble/postamble text around JSON
- Fixes trailing commas (
{"a": 1,}becomes{"a": 1}) - Extracts JSON from surrounding text by bracket matching
Tier 2: Decode and schema validation
After text repair, the output is parsed as JSON, any transport encoding the schema
compiler introduced is reversed (see
optional and nullable fields),
output guardrails run, and the canonical value is validated against your authored
Zod schema with safeParse() — exactly once, on the value you wrote the schema for.
Tier 3: LLM retry (uses a step)
If text repair didn't produce valid output, Crux appends the model's failed output and the Zod validation errors as a corrective user message, then calls the model again. Each retry counts as a step against the maxSteps budget.
Shared step budget
Validation retries share the maxSteps budget with tool-call iterations. This prevents runaway costs:
await adapter.generate(prompt, {
maxSteps: 5, // total budget: tools + validation retries
validationRetry: {
maxRetries: 3, // up to 3 of those 5 steps can be validation retries
},
});If maxSteps is exhausted before maxRetries, the retry stops early.
Streaming
Retrying a stream means the first attempt must never have been shown. So on a
structured stream, a positive maxRetries installs a commit gate: Crux buffers
the candidate, validates it at end-of-stream, and only then releases it.
// Streams incrementally: validation still runs, but there is nothing to retry.
await adapter.stream(prompt, { input });
// Buffers the candidate so a failed attempt can be discarded and retried.
await adapter.stream(prompt, { input, validationRetry: { maxRetries: 2 } });That is a deliberate trade: maxRetries: 0 (or omitting validationRetry)
installs no gate and streams incrementally, but an invalid candidate can only
throw, because part of it has already been published. Choose per call whether you
want time-to-first-token or a retryable stream.
While the gate holds, the stream directive carries a content-free
bufferedBy: 'validation-retry' so the pause can be explained without seeing the
held bytes. A rejected attempt is discarded and re-streamed with corrective
feedback; the consumer never saw it. If retries run out, ValidationExhaustedError
is thrown having published nothing.
Budget arithmetic
Three limits apply, and the tightest one wins:
| Limit | Bounds |
|---|---|
maxSteps | every provider call on the request — tool rounds, validation re-streams, and constraint re-streams together |
validationRetry.maxRetries | how many of those may be validation retries |
a constraint's maxRetries | how many may be retries for that constraint |
So maxSteps: 5 with validationRetry: { maxRetries: 3 } allows at most three
validation re-streams and never more than five provider calls in total; if tool
rounds consume the budget first, retrying stops early even though maxRetries
remains. When both an assert constraint and validation retry gate the same stream
the errors stay distinct — a constraint failure throws ConstraintViolationError, a
validation failure throws ValidationExhaustedError. They are never combined.
A managed stream has no bypass surface. There is no result.raw: a provider
stream resolves before terminal Safety and describes only one attempt, so
reading it would expose content a commit gate withheld — including a rejected
attempt Crux discarded and re-streamed. Everything published on textStream,
fullStream, and partialOutputStream has cleared every gate that could
still block or change it.
Feedback sanitization
Validation retry feeds corrective text back to the model. Crux builds that text from the parse failure and schema issues, not from arbitrary prompt prose, but your schema messages and failed output can still contain user-controlled data.
Use normal prompt sanitization for any user text you place in custom messages. When you need to inspect or redact corrective feedback itself, attach a guardrail to the canonical feedback provenance:
import { boundary, guardrail } from "@use-crux/core/safety";
const redactFeedback = guardrail({
id: "validation-feedback-pii",
on: boundary.input.text({ from: "feedback" }),
run: guardrail.pii({ strategy: "mask" }),
});On a retry, Crux guards the rejected output and the generated feedback
separately with the same one-based ctx.origin.attempt. The guard runs only
after retry eligibility is established. An enforced block prevents the next
physical provider call; report mode records intent and preserves the original
message envelope.
boundary.validation.feedback() remains operational for validation feedback
during its compatibility window, but is deprecated. It does not cover
constraint feedback or rejected output; use the source-filtered input boundary
for all new policies.
Error handling
When all retries are exhausted, ValidationExhaustedError is thrown with context for debugging:
import { ValidationExhaustedError } from "@use-crux/core";
try {
await adapter.generate(prompt, opts);
} catch (err) {
if (err instanceof ValidationExhaustedError) {
err.lastRawOutput; // the model's last invalid output
err.zodErrors; // ZodError with validation details
err.attempts; // how many retries were attempted
err.maxAttempts; // configured maxRetries
err.promptId; // which prompt failed
}
}Fallback composition
Use fallback() when another model should be tried after a qualifying provider
failure or a successful-but-unusable result:
import { fallback } from "@use-crux/core";
const model = fallback([weakModel, strongModel], {
on: ["rate_limit", "timeout", "server_error"],
when: (result) => isUnusableExtraction(result),
});
await adapter.generate(prompt, {
model,
validationRetry: { maxRetries: 2 },
});Validation retry still owns schema repair for each model attempt. The when
predicate is for domain-specific invalid responses that parsed successfully but
should be retried on the next fallback candidate.
Multi-agent composition
All composition patterns accept validationRetry as a top-level option:
// Pipeline, default for all agent steps
await pipeline({
id: "validation-retry-pipeline-1",
context: { userId },
validationRetry: { maxRetries: 3 },
steps: [
{ name: "extract", agent: extractorAgent },
{ name: "classify", agent: classifierAgent },
],
});
// Parallel, all agents get validation retry
await parallel({
id: "validation-retry-parallel-1",
context: { document },
agents: { entities: entityAgent, sentiment: sentimentAgent },
validationRetry: { maxRetries: 2 },
});
// Swarm, each agent turn gets validation retry
await swarm({
id: "validation-retry-swarm-1",
agents: { router: routerAgent, writer: writerAgent },
startAgent: "router",
input: query,
validationRetry: { maxRetries: 3 },
});Flows
In flows, you call generate() directly inside flow.step(): pass validationRetry to your generate call:
const extractData = flow(
"extract data",
async (flow, input: { goal: string }) => {
const data = await flow.step("extract", async () => {
return adapter.generate(extractPrompt, {
model: "claude-sonnet-4-20250514",
input: { goal: input.goal, text: document },
validationRetry: { maxRetries: 3 },
});
});
return data;
},
);
const result = await extractData.run({ goal: "Extract data" });Flow steps also support step.retry for transient failures (network errors, rate limits). Both layers compose naturally: step.retry wraps the entire step, validationRetry operates inside generate().
Hooks
Use onRetry and onExhausted callbacks for logging and metrics:
validationRetry: {
maxRetries: 3,
onRetry: (attempt, zodError) => {
logger.warn(`Validation retry ${attempt}`, { error: zodError.message })
metrics.increment('validation_retry')
},
onExhausted: (attempts, lastError) => {
logger.error(`Validation exhausted after ${attempts} attempts`, { error: lastError.message })
metrics.increment('validation_exhausted')
},
}Retry events are also emitted to:
- Devtools: visible in the trace timeline as retry attempt/exhaustion events
- OpenTelemetry: spans with
crux.validation.*attributes via@use-crux/otel
Text repair utility
repairJsonText() is exported for standalone use in your own pipelines:
import { repairJsonText } from "@use-crux/core";
const fixed = repairJsonText('```json\n{"name": "Alice"}\n```');
// returns: '{"name": "Alice"}'
const broken = repairJsonText("not json at all");
// returns: null