Self-Correcting Output
Self-Correcting Output: the pattern that makes LLM components shippable. The model's output is validated against the capability's contract; violations don't raise to the user - they become the CORRECTION PROMPT for a bounded retry. The contract is the editor, the model is the writer, and the loop converges or fails honestly with the full paper trail.
Reliability & Recovery
Round 12
Obie Fernandez
exit 0
bundle exec ruby examples/self_correcting_output.rb
a real captured run
SELF-CORRECTING OUTPUT (the contract is the editor)
attempt 1: {"vendor":"Initech Supply Co","total_cents":"4200","currency":"usd"}
-> rejected by the contract; violations become the next prompt:
Your previous answer violated the output contract:
- total_cents: must be Numeric
- currency: must be one of: USD, EUR, GBP
- due_date: is missing
Return the SAME data corrected to satisfy every constraint. Do not apologize; return JSON.
attempt 2: {"vendor":"Initech Supply Co","total_cents":4200,"currency":"USD","due_date":"2026-08-01"}
-> contract satisfied. shipped after 2 attempt(s).
the shape of the trick: nothing here trusts the model, and
nothing here burdens the user. the contract that documents the
capability (rounds 5-11 built six tools on it) turns out to be
the exact artifact a correction loop needs - violations arrive
pre-written as actionable feedback ("currency: must be one of:
USD, EUR, GBP"), which beats "please try again" by exactly the
margin your production error rate will show. the loop is
bounded (3 attempts - unbounded self-correction is a billing
strategy), each retry costs one more model call and is worth
it, and when it fails it fails with every draft on record.
ship the editor with the writer; never ship the writer alone.
source
# frozen_string_literal: true # Self-Correcting Output: the pattern that makes LLM components # shippable. The model's output is validated against the capability's # contract; violations don't raise to the user - they become the # CORRECTION PROMPT for a bounded retry. The contract is the editor, # the model is the writer, and the loop converges or fails honestly # with the full paper trail. # # bundle exec ruby examples/self_correcting_output.rb # # Runs offline; the "model" is scripted to be sloppy, then coachable. require class="s">"bundler/setup" require class="s">"agentic" require class="s">"json" Agentic.logger.level = class="y">:fatal CONTRACT = Agentic:class="y">:CapabilitySpecification.new( name: class="s">"extract_invoice", description: class="s">"Extract structured invoice data", version: class="s">"1.0.0", inputs: {text: {type: class="s">"string", required: true}}, outputs: { vendor: {type: class="s">"string", required: true, non_empty: true}, total_cents: {type: class="s">"number", required: true, min: 0}, currency: {type: class="s">"string", required: true, enum: %w[USD EUR GBP]}, due_date: {type: class="s">"string", required: true} } ) VALIDATOR = Agentic:class="y">:CapabilityValidator.new(CONTRACT) # The "model": pass 1 is what models actually do to schemas; pass 2 # reads the corrections like a chastened intern MODEL = lambda do |prompt, attempt| if attempt == 1 # currency invented, total as a string, due_date forgotten {vendor: class="s">"Initech Supply Co", total_cents: class="s">"4200", currency: class="s">"usd"} else # the correction prompt names each violation; the model complies {vendor: class="s">"Initech Supply Co", total_cents: 4200, currency: class="s">"USD", due_date: class="s">"2026-08-01"} end end def correction_prompt(violations) lines = violations.map { |field, messages| class="s">"- #{field}: #{messages.join("; class="s">")}" } class="s">"Your previous answer violated the output contract:\n#{lines.join("\nclass="s">")}\n" \ class="s">"Return the SAME data corrected to satisfy every constraint. Do not apologize; return JSON." end MAX_ATTEMPTS = 3 INVOICE = class="s">"Invoice from Initech Supply Co, total $42.00, due Aug 1 2026" puts class="s">"SELF-CORRECTING OUTPUT (the contract is the editor)" puts attempt = 0 output = nil prompt = class="s">"Extract the invoice fields from: #{INVOICE}" loop do attempt += 1 output = MODEL.call(prompt, attempt) puts class="s">" attempt #{attempt}: #{JSON.generate(output)}" begin VALIDATOR.validate_outputs!(output) puts class="s">" -> contract satisfied. shipped after #{attempt} attempt(s)." break rescue Agentic:class="y">:Errors:class="y">:ValidationError => e if attempt >= MAX_ATTEMPTS puts class="s">" -> #{MAX_ATTEMPTS} attempts exhausted; failing HONESTLY with the paper trail." raise end puts class="s">" -> rejected by the contract; violations become the next prompt:" correction = correction_prompt(e.violations) correction.lines.each { |l| puts class="s">" #{l.chomp}" } prompt = correction end end puts puts class="s">" the shape of the trick: nothing here trusts the model, and" puts class="s">" nothing here burdens the user. the contract that documents the" puts class="s">" capability (rounds 5-11 built six tools on it) turns out to be" puts class="s">" the exact artifact a correction loop needs - violations arrive" puts class="s">" pre-written as actionable feedback (\"currency: must be one of:" puts class="s">" USD, EUR, GBP\"), which beats \class="s">"please try again\" by exactly the" puts class="s">" margin your production error rate will show. the loop is" puts class="s">" bounded (#{MAX_ATTEMPTS} attempts - unbounded self-correction is a billing" puts class="s">" strategy), each retry costs one more model call and is worth" puts class="s">" it, and when it fails it fails with every draft on record." puts class="s">" ship the editor with the writer; never ship the writer alone."