Capstone: an agent that checks invoices
Tools over OCR text, a loop with a budget and a guard against repeats, and a report a person can read. Three invoices, two with a problem.
Combines the 7 exercises of the Agents and perception track. It unlocks when you finish them, but you can try it now.
INVOICES are three invoices as the OCR read them. parseToolCall, extractFields, validateCIF, validateNIF and checkTotals are already written (the ones from the earlier exercises). Each tool costs what COST says. Write four functions:
makeTools(invoices): returns { listar, leer, revisar }. listar() gives the numbers joined by ", "; leer({ id }) gives that invoice's text or "error: no existe " + id; revisar({ id }) returns, as JSON text, { id, cif_ok, nif_ok, totales_ok, diff } using the functions already written (or the same error when it does not exist).
agentLoop(model, task, tools, maxSteps, budget): as in the agent exercise, with three more rules. An unknown tool returns "error: herramienta desconocida: " + name and costs nothing. A call identical to an earlier one (same tool and same arguments) returns "error: llamada repetida", costs nothing and adds one to repeats. When the accumulated cost plus the call's cost would exceed budget, stop before running it with stopped: "budget". Return { answer, steps, stopped, cost, repeats }; in steps, every executed tool stores { tool, args, result }.
finalReport(steps): from the revisar results that are JSON, { revisadas: [ids in order], problemas: [{ id, motivo }] } with motivo "cif", "nif" or "totales" (one entry per failed reason, in that order), without repeating invoices.
runAudit(model, invoices): runs agentLoop with TASK, makeTools(invoices), 12 steps and budget 20, and returns { answer, stopped, cost, report } with report = finalReport(steps). The preview shows the transcript, the cost and the report.
Challenges 0/6
- The tools: listar, leer and revisar (with the error for a missing id)
- The budget stops the loop before the call that would exceed it
- A repeated call and an unknown tool neither cost nor break
- The report comes from the revisar results
- The whole audit: answer, cost and report
- A model that never concludes runs out of steps, with a partial report
function makeTools(invoices) {
// listar: the ids joined by ", "; leer: the OCR text or "error: no existe " + id
// revisar: JSON text of { id, cif_ok, nif_ok, totales_ok, diff } from extractFields / validateCIF / validateNIF / checkTotals
return { listar: () => '', leer: () => '', revisar: () => '' };
}
function agentLoop(model, task, tools, maxSteps, budget) {
// as in the agent exercise, plus: unknown tool -> "error: herramienta desconocida: " + name (free)
// repeated call (same tool + args) -> "error: llamada repetida" (free, repeats + 1)
// cost + COST[tool] > budget -> stop before running it, stopped: "budget"
return { answer: null, steps: [], stopped: 'max', cost: 0, repeats: 0 };
}
function finalReport(steps) {
// { revisadas: [ids], problemas: [{ id, motivo }] } from the revisar results that parse as JSON
return { revisadas: [], problemas: [] };
}
function runAudit(model, invoices) {
// agentLoop with TASK, makeTools(invoices), 12 steps, budget 20; report = finalReport(steps)
return { answer: null, stopped: 'max', cost: 0, report: finalReport([]) };
}
console.log(runAudit(scriptedModel(SCRIPT), INVOICES));The idea
An agent in production is not "the model with tools". It is the model, plus the tools, plus three defences you write: a step cap, a budget and a guard against repeats. All three exist because small models often do three things: keep asking for tools without concluding, repeat the same call and, now and then, invent a tool.
The tools are where the truth lives. revisar does not "believe" the CIF is wrong: it computes it with the control sum and returns JSON your report can read without parsing prose. Whatever the model says in its final reply is a summary for the person; the report comes from the tool results, which is why it is reliable even when the model phrases things badly.
What we measured: the three invoices come from tesseract.js on synthetic images of ours (290–475 ms each on the DGX Spark, 2026-10-18). The script in this exercise mirrors what an 8B model does in our tests on tasks like this: list, check one by one, repeat one check and conclude. On the repeated check the guard saves 2 of cost; on a task with a hundred invoices, it saves the whole budget.
If you take this to your company, the decision that matters is what each tool is allowed to do. revisar only reads. A tool that "pays" or "sends" needs a human confirmation outside the loop, and that is not a limitation of AI, it is design.
Run it on your machine
With Python, on the batch of OCR-read invoices (CC BY 4.0). The same checks, with no model:
curl -O https://vorluxai.com/data/labs/facturas-ocr-lote.json
python3 -c "
import json, re
def cif_ok(c):
s = sum((lambda n: n // 10 + n % 10)(int(d) * 2) if i % 2 == 0 else int(d) for i, d in enumerate(c[1:8]))
return c[8] == str((10 - s % 10) % 10)
amt = lambda t, k: float(re.search(k + r'[^\n]*?(\d[\d.]*,\d{2})', t, re.I).group(1).replace('.', '').replace(',', '.'))
for id, t in json.load(open('facturas-ocr-lote.json'))['invoices'].items():
cif = re.search(r'\b([A-HJ-NP-SUVW]\d{7}[0-9A-J])\b', t).group(1)
diff = round(amt(t, 'Base imponible') + amt(t, 'IVA') - amt(t, 'TOTAL'), 2)
print(id, 'cif ok' if cif_ok(cif) else 'CIF MAL', 'totales ok' if diff == 0 else f'TOTALES MAL ({diff})')
" Go deeper: the Ollama reference →
This in production, with your data? Let's talk for 15 minutes →