Agents and perceptionCapstone · Hard · 35 min

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));
Console output appears here (console.log).

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})')
"

Check what you learned

1. The model asks for `revisar` twice with the same id. What does your loop do?
2. Where does the final report come from?
3. Cost stands at 19 and the next call costs 2 with a budget of 20. What happens?
4. Which tool of this agent would need a human confirmation if it existed?

Go deeper: the Ollama reference →

This in production, with your data? Let's talk for 15 minutes →