<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Breno Luiz</title>
    <description>The latest articles on DEV Community by Breno Luiz (@breno_luiz_ab86a62d8c5c7c).</description>
    <link>https://dev.to/breno_luiz_ab86a62d8c5c7c</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102030%2Fb8b9c97d-cdde-4cb3-ad06-d4f7e72910f6.jpg</url>
      <title>DEV Community: Breno Luiz</title>
      <link>https://dev.to/breno_luiz_ab86a62d8c5c7c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/breno_luiz_ab86a62d8c5c7c"/>
    <language>en</language>
    <item>
      <title>The agent that refuses to guess</title>
      <dc:creator>Breno Luiz</dc:creator>
      <pubDate>Mon, 31 Aug 2026 03:24:41 +0000</pubDate>
      <link>https://dev.to/breno_luiz_ab86a62d8c5c7c/the-agent-that-refuses-to-guess-2364</link>
      <guid>https://dev.to/breno_luiz_ab86a62d8c5c7c/the-agent-that-refuses-to-guess-2364</guid>
      <description>&lt;p&gt;I work at a B2B telecom consultancy. I'm not the one auditing the bills, but every month I&lt;br&gt;
watch how it's done: open the invoice PDF, check every line against the signed contract,&lt;br&gt;
compare it with what the account used in earlier cycles, and write up whatever doesn't add&lt;br&gt;
up.&lt;/p&gt;

&lt;p&gt;It's slow, it doesn't scale, and it's the first task dropped when the month gets busy —&lt;br&gt;
which is exactly when the money leaks. And outside a consultancy it's worse: most companies&lt;br&gt;
just pay the bill because it arrived.&lt;/p&gt;

&lt;p&gt;So I built an agent that does the whole job. One invoice PDF in; a dispute letter for the&lt;br&gt;
carrier and an executive summary for the customer out, with nobody in the loop. It runs on&lt;br&gt;
Gemini 3.5 Flash and the Google ADK, on Cloud Run and Firestore, and it's open source:&lt;br&gt;
&lt;a href="https://github.com/Bren0-lz/invoice-sentinel" rel="noopener noreferrer"&gt;github.com/Bren0-lz/invoice-sentinel&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is about the one decision that shaped everything else, and about the four defects&lt;br&gt;
that only showed up when I stopped reading my own code and started attacking the running&lt;br&gt;
service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: no money figure ever comes out of the LLM
&lt;/h2&gt;

&lt;p&gt;An agent that writes a dispute letter is writing a document addressed to a third party and&lt;br&gt;
signed by the customer. If a figure in it is invented, the customer doesn't lose a feature&lt;br&gt;
— they lose credibility with their own supplier, and they lose it in writing.&lt;/p&gt;

&lt;p&gt;So the rule is absolute, and it isn't a line in a prompt. It's enforced in three layers,&lt;br&gt;
each of which would have to fail independently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structurally.&lt;/strong&gt; The rule engine is pure Python with &lt;code&gt;Decimal&lt;/code&gt;. No module under &lt;code&gt;rules/&lt;/code&gt;&lt;br&gt;
imports an LLM client. Five rules across three families, running concurrently under a&lt;br&gt;
&lt;code&gt;ParallelAgent&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the tool signatures.&lt;/strong&gt; No auditor tool accepts a monetary value as an argument.&lt;br&gt;
&lt;code&gt;flag_anomaly(finding_id, rationale)&lt;/code&gt; cannot be talked into disputing four thousand reais&lt;br&gt;
that nobody computed, because there is no parameter to put it in. A test asserts this with&lt;br&gt;
&lt;code&gt;inspect.signature&lt;/code&gt;, so the guarantee survives someone adding a tool later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the generated prose.&lt;/strong&gt; &lt;code&gt;amount_guard&lt;/code&gt; extracts every money-shaped number from the&lt;br&gt;
letter and checks it against the set the engine actually computed. Invented one? The model&lt;br&gt;
is told &lt;em&gt;which&lt;/em&gt; number and rewrites it. Insisted past the rewrite budget? The dispute is&lt;br&gt;
stored as &lt;code&gt;blocked&lt;/code&gt; — never as a &lt;code&gt;draft&lt;/code&gt; somebody might later mistake for reviewed.&lt;/p&gt;

&lt;p&gt;The third layer is the one people forget, and the one that matters most. The first two make&lt;br&gt;
the model unable to author a figure; only the third checks what it actually wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other decision: refuse rather than guess
&lt;/h2&gt;

&lt;p&gt;The agent ships layout profiles for two carriers. Send it a bill from a third and it&lt;br&gt;
refuses, rather than reading it with someone else's separator hints — because a Brazilian&lt;br&gt;
&lt;code&gt;1.234,56&lt;/code&gt; read as American is &lt;code&gt;1.234&lt;/code&gt;, and that is the shape of error nothing downstream&lt;br&gt;
catches. It's plausible. It's wrong. It's a thousand times too small.&lt;/p&gt;

&lt;p&gt;The same principle runs through the whole system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Name a carrier the bill contradicts, and it stops &lt;strong&gt;before&lt;/strong&gt; paying for an extraction.&lt;/li&gt;
&lt;li&gt;Send a PDF with no readable text layer and no carrier named, and it asks instead of
falling back to a default.&lt;/li&gt;
&lt;li&gt;Audit an account with no contract on file, and it says it &lt;em&gt;could not audit&lt;/em&gt; the invoice.
It never says the bill looks clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one was a real defect, and it's the best lesson in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four defects the tests could not have found
&lt;/h2&gt;

&lt;p&gt;Every one of these came from using the deployed service the way an evaluator would, with&lt;br&gt;
documents nobody prepared knowing what would be checked.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A health certificate for an invoice that was never audited
&lt;/h3&gt;

&lt;p&gt;With no contract on file, three of five rules are skipped and the findings list comes back&lt;br&gt;
empty. The prompt then told the model to say the invoice "looks clean".&lt;/p&gt;

&lt;p&gt;A brand-new account is the first path anyone walks. It got that certificate over a bill&lt;br&gt;
that had a real overcharge — which appeared the moment the contract was filed. Worse, the&lt;br&gt;
rule for escalating a missing contract existed ten lines below in the same prompt and was&lt;br&gt;
&lt;strong&gt;unreachable&lt;/strong&gt;: escalation needs a &lt;code&gt;finding_id&lt;/code&gt;, and there are no findings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silence from a rule that never ran is not evidence that the bill is correct.&lt;/strong&gt; Empty now&lt;br&gt;
branches on whether a contract exists, before it can mean anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The model invented contract clauses
&lt;/h3&gt;

&lt;p&gt;Transcribing a three-plan contract, it filed six — helpfully re-adding each plan under the&lt;br&gt;
abbreviated name the invoice prints, so whoever had to match the two documents would have&lt;br&gt;
an easier time.&lt;/p&gt;

&lt;p&gt;The contract is the baseline every figure is computed against. It now contained terms&lt;br&gt;
nobody signed. Fixed with a validator that rejects duplicate plans and a repair loop that&lt;br&gt;
sends the model back, not with a sterner prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A correct transcription that was still a wrong audit
&lt;/h3&gt;

&lt;p&gt;A Brazilian invoice was transcribed perfectly and still reported 149.42 out of balance.&lt;/p&gt;

&lt;p&gt;ICMS, PIS, COFINS, FUST and FUNTTEL are computed &lt;em&gt;inside&lt;/em&gt; the price here and itemised only&lt;br&gt;
because Lei 12.741/2012 requires it. Summing them double-counts. The schema now declares&lt;br&gt;
the tax regime per carrier — inclusive in Brazil, additive in the US — so the balance&lt;br&gt;
warning means what it says instead of firing on every bill from a country whose invoices&lt;br&gt;
are all built that way.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The money parser was losing the minus sign four different ways
&lt;/h3&gt;

&lt;p&gt;This is my favourite, because I found it by going looking.&lt;/p&gt;

&lt;p&gt;The parser that coerces a transcribed amount into &lt;code&gt;Decimal&lt;/code&gt; strips currency noise with a&lt;br&gt;
regex that keeps only digits, separators and an &lt;strong&gt;ASCII hyphen&lt;/strong&gt;. So:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Printed on the bill&lt;/th&gt;
&lt;th&gt;Parsed as&lt;/th&gt;
&lt;th&gt;Should be&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;−50,00&lt;/code&gt; (U+2212, what a properly typeset PDF prints)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+50.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-50.00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;–50,00&lt;/code&gt; (en dash, from a layout pasted out of a word processor)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+50.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-50.00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;—50,00&lt;/code&gt; (em dash)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+50.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-50.00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;(50,00)&lt;/code&gt; (accounting notation)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;+50.00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-50.00&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A fifty-real credit became a fifty-real charge, and the invoice total it reconciles against&lt;br&gt;
moved by a hundred.&lt;/p&gt;

&lt;p&gt;Here's what makes it worth writing about: &lt;strong&gt;nothing downstream could have caught it.&lt;/strong&gt; The&lt;br&gt;
three layers protecting the money protect against the &lt;em&gt;model&lt;/em&gt; authoring a figure. They say&lt;br&gt;
nothing about a figure entering wrong. &lt;code&gt;amount_guard&lt;/code&gt; compares the letter against what the&lt;br&gt;
engine computed — it cannot compare the engine against the page. The sign was gone before&lt;br&gt;
the value was ever a &lt;code&gt;Decimal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The parser had no tests at all. The deterministic safety net under the project's central&lt;br&gt;
claim, with zero coverage.&lt;/p&gt;

&lt;p&gt;It now normalises the dash forms and reads parentheses as negative. And a value that states&lt;br&gt;
its sign twice — &lt;code&gt;(-50,00)&lt;/code&gt; — is &lt;strong&gt;refused&lt;/strong&gt; rather than resolved, because picking one&lt;br&gt;
would be guessing about money.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building an agent that touches money
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Make the guarantee structural, not textual.&lt;/strong&gt; Every time I needed something to be true, a&lt;br&gt;
prompt was the weakest place to put it. A validator, a function signature or a type has to&lt;br&gt;
be actively defeated; a prompt just has to be forgotten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the deployed thing, with documents you didn't make.&lt;/strong&gt; My whole synthetic dataset&lt;br&gt;
comes from one generator, so it proves the rule engine agrees with the generator — a weaker&lt;br&gt;
claim than it looks. The first time I audited an invoice built independently, three defects&lt;br&gt;
surfaced at once, and all three were &lt;em&gt;false positives&lt;/em&gt;: findings that weren't there. That&lt;br&gt;
document is now committed as a regression fixture, as the false-positive control with the&lt;br&gt;
generator taken out of the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch what a refusal actually stops.&lt;/strong&gt; I added a refusal at the intake stage and it&lt;br&gt;
changed nothing: sibling stages of a &lt;code&gt;SequentialAgent&lt;/code&gt; can't be skipped from outside, and&lt;br&gt;
the extractor was reading the attachment on its own and extracting anyway, right past the&lt;br&gt;
"no". A refusal is a text in the transcript until something downstream asks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Count your model calls, honestly.&lt;/strong&gt; Only three of eleven pipeline stages are &lt;code&gt;LlmAgent&lt;/code&gt;s.&lt;br&gt;
Deciding whether an attachment is a contract or a bill is pattern matching; so is picking a&lt;br&gt;
carrier profile off a letterhead. A model call whose only job is to invoke a function is&lt;br&gt;
tokens spent on nothing, and it dresses deterministic work up as reasoning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Measured on 15 synthetic invoices, 4 accounts, 2 visually distinct carrier layouts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;99.55%&lt;/strong&gt; field accuracy on extraction — 1781/1789 values, 0 repair rounds needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5/5&lt;/strong&gt; anomalies found, &lt;strong&gt;0&lt;/strong&gt; false positives&lt;/li&gt;
&lt;li&gt;Recovered total &lt;strong&gt;1,036.10&lt;/strong&gt; against 1,036.10 ground truth, to the cent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;203 tests&lt;/strong&gt;, fully offline — no credentials, no tokens, about one second&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Clone it and run &lt;code&gt;pytest&lt;/code&gt;; the accuracy claims verify themselves against extractions&lt;br&gt;
committed in the repo.&lt;/p&gt;

&lt;p&gt;Honest limitations, since I'd rather say them than have you find them: two carriers only,&lt;br&gt;
the dataset is synthetic, and the audited PDF isn't stored yet, so an audit can't be&lt;br&gt;
re-checked against the document that produced it.&lt;/p&gt;

&lt;p&gt;Built for the &lt;a href="https://allthingsagentichackathon.devpost.com/" rel="noopener noreferrer"&gt;All Things Agentic&lt;br&gt;
Hackathon&lt;/a&gt;, track &lt;em&gt;The Taskmaster&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Code: &lt;a href="https://github.com/Bren0-lz/invoice-sentinel" rel="noopener noreferrer"&gt;github.com/Bren0-lz/invoice-sentinel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
