Crux
GuidesSafety

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

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: Schema validation

After text repair, the output is parsed as JSON and validated against your Zod schema using safeParse().

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.

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 boundary.validation.feedback():

import { boundary, guardrail } from "@use-crux/core/safety";

const redactFeedback = guardrail({
  id: "validation-feedback-pii",
  on: boundary.validation.feedback(),
  run: guardrail.pii({ strategy: "mask" }),
});

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

On this page