Validation and retry

Every AI call needs its own parsing, validation and retry code.

Asking a model for JSON is the easy part: reading the reply into your type, checking it, asking again with what went wrong and stopping in time is the code that gets written again around every call. With IntentSharp that work comes with the call, so you state the rules and the limits and get back a result that says what was checked.

What it costs today

By hand, each call site reads the reply into your type and turns a parse error or a broken business rule into a message for the model. Then it asks again, within the attempts and the time you allow, keeps a record of every attempt for the logs and tells a spent time budget from a caller's cancel.

The outline is the shape of that code, not a working version. It comes back, with small differences, at every call site, and each copy needs its own tests.

ExtractAsync, by handOutline
public static async Task<OrderRequest?> ExtractAsync(IChatClient chat, string email, CancellationToken ct = default){    // 1. Ask the model; read the reply as OrderRequest; a missing member is a finding.    // 2. Run each business rule on the value; collect the findings.    // 3. On a finding, ask again with it, within your attempt and call limits.    // 4. Stop at the time budget, and tell it apart from the caller's cancel.    // 5. Keep each attempt's reply, findings and tokens; return null or throw.}

How IntentSharp handles it

The rules and the limits are options on one call. The first email's first answer writes the SKU without its hyphen: the rule refuses it, and the second attempt passes. The second email gives no quantity, so each answer leaves the nullable Quantity null, every attempt fails the quantity rule, and the call ends after three attempts as a failed result, with every finding listed.

Program.cs
// The rules and the limits are options on the call. The parse,// the checks, the retry with the findings and the attempt log are// the call's own.var options = new AIRequestOptions{    MaxAttempts = 3,    MaxModelCalls = 4,    Timeout = TimeSpan.FromSeconds(20),}.Require<OrderRequest>(o => o.Quantity is > 0,    "Quantity must be a positive number.").Require<OrderRequest>(o => Regex.IsMatch(o.Sku, @"^[A-Z]-\d{4}$"),    "Sku must look like A-1192."); var order = await email.AIAsync<OrderRequest>(    client, "Extract the purchase request from this email.", options);
Output: each attempt, then the result
Email 1:  Attempt 1 passed: false    [Validator] attempt 1: Sku must look like A-1192.  Attempt 2 passed: true  Success: true, after 2 attempts  Value: OrderRequest { Contact = Dana (Northwind), Sku = A-1192, Quantity = 40, DeliverBy = 2026-11-15 }Email 2, which gives no quantity:  Attempt 1 passed: false    [Validator] attempt 1: Quantity must be a positive number.  Attempt 2 passed: false    [Validator] attempt 2: Quantity must be a positive number.  Attempt 3 passed: false    [Validator] attempt 3: Quantity must be a positive number.  Success: false, after 3 attempts  FailureSummary: Quantity must be a positive number.

OrderRequest is Contact, Sku, Quantity and DeliverBy. Quantity is an int?, so an answer can leave it null when the email gives none, and the rule refuses the null. Compiled and run with every build of this site; the model's answers are scripted, and a pass means the answer met the checks, not that it is right.

What stays in your control

You set the rules and the limits. The call applies them in the same way every time, and the result says what happened.

  • The checks. Every answer is checked in your process before your code sees it: a refusal, a cut-off or empty reply, your type (required members, nulls, enum names), then your Require<T> and Validate<T> rules. No check calls a model, except the optional judge.
  • The retry. A failed check is retried with the previous reply, up to 6,000 characters, and the findings. A refusal is not retried.
  • The limits. MaxAttempts (2 by default), MaxModelCalls (6, judge calls included), MaxOutputTokens (4,096) and the time budget (none by default), on the client or per call. MaxModelCalls and the time budget end the call with Success false and a Guardrail issue that names the limit. When the attempts run out, Success is false and Issues holds each attempt's findings, as for the second email. A reply cut off at MaxOutputTokens fails its attempt with an issue that names the limit, and the next attempt may still pass. The limits count calls, tokens and time, not money.
  • What a failure looks like. A wrong answer, a refusal, a provider error or a reached limit does not throw: Success is false and Issues says why, each finding with its source and attempt. A call throws only for a mistake in the call itself, before any model call.
  • The record. Attempts holds one record per attempt that finished its checks: provider, model, tokens, time, the reply and its issues.
  • A pass is not a proof. A pass means the checks passed, not that the answer is right.

Delete the retry loop from your next call.

dotnet add package IntentSharp --prerelease