DEV Community

Efthimios
Efthimios

Posted on

Keeping Strands agents honest in a household money app

Many household money leaks are small and quiet. A washing machine breaks a few months before its guarantee runs out, and the repair gets paid without anyone asking the seller. A free trial turns into a monthly charge. A card payment has no receipt by the time someone needs one. None of this is hard to spot. It is just easy to miss.

An agent sounds like a good fit: read the records, point at what needs a decision. The catch is that a language model writing about guarantees and money can sound certain about things nobody checked, like a refund being owed or a deadline that does not exist. Hestia, built for the AWS Agents for Humans hackathon (Everyday Agents track), tries a narrow version: the model reads and points, plain Python works out the dates and amounts, and the household decides. Here is how its two Strands agents are built and what stops them from overstating.

Two agents, no write tools

Both agents use Strands Agents 1.53.0 and call Claude Haiku 4.5 on Amazon Bedrock through the EU inference profile. Both run inside the same Lambda function, the one that handles POST routes, and neither has a tool that writes a record, prepares a notice or sends anything. The review agent reads a household through four tools and writes a short briefing. The reading agent has no tools and turns pasted text into proposed records. Both work inside a stored demo copy of a fictional household whose access lasts 30 minutes.

The review agent and its four read-only tools

Each review builds a fresh Agent with a BedrockModel (temperature=0.2, streaming=False, 700 output tokens), the tools and a system prompt, then calls it once, asking it to use every tool once and write the briefing.

The tools are plain Python closures over the loaded private copy and the review date:

Tool Reads
review_repair_evidence(appliance_id) one appliance, its seller and any saved case
audit_subscriptions() trials, price changes and duplicates
check_receipts_and_utilities() missing receipts and bills above baseline
read_case_timeline() saved case status, next step and recent events

Strands builds each schema from the signature and docstring, so wrapping is one line. From src/hestia/agents/household_agent.py:

def tool_functions(state: dict[str, Any], today: date) -> dict[str, Callable[..., str]]:
    ...
    def audit_subscriptions() -> str:
        """Inspect recorded recurring charges for trial end dates, price changes and overlaps.

        Amounts are recorded monthly charges, not measured waste or savings.
        """
        ...

def strands_tools(state: dict[str, Any], today: date) -> list[Any]:
    """Wrap the workspace callables as Strands tools (schemas come from signatures and docs)."""
    from strands import tool

    return [tool(func) for func in tool_functions(state, today).values()]
Enter fullscreen mode Exit fullscreen mode

The sentence the model reads to pick a tool is the sentence a reviewer reads in the source. And because each tool closes over one private copy, no tool takes an argument that could reach another household.

Tool outputs are clipped to 1600 characters. After the run, the trace is rebuilt by pairing toolUse and toolResult blocks in agent.messages, and token usage comes from result.metrics.accumulated_usage. Both are stored with the briefing.

The system prompt says: use the tools, never state or imply entitlement to a refund, repair or amount, never invent deadlines, use only amounts and dates from tool outputs, do not draft the notice, and write under 180 words in three sections (What I checked, Decisions waiting for you, Suggested next step).

A prompt is a request, not a control. So the code checks the answer.

The guard withholds, it never rewrites

guard_narrative runs locally on the finished briefing. Any reason it returns withholds the whole briefing. It fires when the text:

  • matches a banned pattern, such as "entitled to", "must refund", "deadline of" or "you are owed";
  • names a euro amount (written with € or EUR) found in none of the outputs of the tools that ran;
  • runs past 3200 characters or never mentions review.

An empty reply is withheld too.

The amount check matters most. Tools write "EUR 185.00", a bare "13.99" or a count of cents, so the guard strips ISO dates from the tool outputs (a date must not lend its digits to an invented figure), normalises every number and also reads whole numbers as cents. From the tests:

assert ha.guard_narrative("A €1,399.00 fee requires review.", ["1399 minor units"]) == []
assert ha.guard_narrative("A €14.00 fee requires review.", outputs)  # 14 only appears in dates
assert ha.guard_narrative("A €777.77 fee requires review.", outputs)
Enter fullscreen mode Exit fullscreen mode

A withheld briefing is labelled as withheld, and the tool trace still shows, because the tool outputs come from the tools, not from the model's text. The guard does not patch text; a guard that rewrote model output would be a second author nobody reviewed.

Its limits: it is a local pattern check, not Amazon Bedrock Guardrails. It does not check dates or amounts without a currency, and a wording its patterns miss would pass. The 180 word ceiling is only an instruction; the code enforces 3200 characters.

The tools-only fallback

When the model is not available, the review route runs the same tools directly (the repair tool once for each appliance with a recorded repair) and returns their outputs with no narrative and a visible reason. It answers HTTP 200 either way.

Reasons decided before any model call are model_not_configured, session_cap, daily_cap and budget_unconfirmed. Once the run starts, they are model_timeout and model_error:<ExceptionClass>. The timeout is a 20 second join on a worker thread, so the thread is not stopped and the attempt stays counted.

The reading agent turns text into proposals

Typing a receipt is dull, so the household can paste a receipt, order email or statement excerpt of up to 6000 characters. A second Agent reads it with tools=[] and temperature=0.0.

The prompt asks for {"records": [...]} and nothing else: a field only when the text states it, no guessed date, price, email or model number, integer cents, at most 20 records. The code does not trust the reply. It parses from the first { to the last }, then normalise_extracted keeps at most 20 records, three kinds (appliance, transaction, subscription), their allowed keys and plain values only.

The proposal is stored as a staged draft in the same form manual entry uses. Household records change only after the household reviews the draft, corrects what is wrong, and commits it with confirmed: true.

  • The raw pasted text is not retained in Hestia's state. Hestia stores its SHA-256, byte count, proposed records, model id and usage. The same text pasted again replays the draft with no model call.
  • With no deterministic reader to fall back to, the route fails closed: 503 when no model is configured or the daily budget is spent or unconfirmed, 429 when this copy has used its three readings or hit its draft limit, and 502 when the model times out, fails or returns an unreadable reply. These paths create no intake draft or household record. A 502 still stores an audit event and input hash, and may retain usage accounting.
  • A failure raised by the model call, such as a Bedrock service error, hands the reading back. A timeout or unreadable reply stays counted. Either way, the unit taken from the daily budget stays spent.

Extraction accuracy is unmeasured. The control is that a person confirms every fact.

Bounding cost

The demo is a public link with no login, so limits live on the server: up to 3 model-backed reviews per private copy, after which reviews remain tools-only; up to 3 counted pasted-text model attempts per private copy; 200 shared model-operation reservations per UTC day across all visitors; 700 output tokens per underlying model request; a 20-second application wait limit that returns a fallback or error but does not cancel an in-flight worker thread; and 40 application actions per private copy. One review may make several underlying model requests during its tool loop. Cost per call is unmeasured.

The daily counter is one S3 object per day. The first call creates it with If-None-Match: *; later calls write the new count with If-Match on its ETag, making up to four attempts when a race is lost. If the count cannot be confirmed, the model is not called.

One lesson from it: the Lambda roles have no s3:ListBucket, and without it S3 answers a read of a missing key with AccessDenied, not NoSuchKey. The first release of the agent took that as an unconfirmed budget, and the live review silently fell back to tools only. The fix treats AccessDenied on that read as "maybe missing" and proves absence with the conditional create. A real permission failure still fails closed, and a test covers it with a fake S3 that imitates the missing permission.

Deployment

Two CloudFormation stacks run in eu-west-1: the web app on Amazon CloudFront over a private S3 bucket, and an API Gateway HTTP API over two Python 3.11 Lambda functions. The reader answers every GET and its role denies bedrock:*. The writer handles POST routes and may invoke only the one Haiku inference profile and its foundation model. Both roles deny ses:* and object deletes.

The Lambda package installs pinned boto3 and Strands Agents versions, including strands-agents==1.53.0, and checks inside the bundle that Agent, tool and BedrockModel import. The backend ships from the CI artifact of the exact commit: an operator prepares a CloudFormation change set, reads it, then runs the execute step, which refuses a set with a Remove action or Replacement: True and then checks that both functions report the commit. The web app ships through a GitHub OIDC workflow that refuses to publish unless the live /healthz names the approved backend commit with a live model and sending off.

The live revision is f58934736ec3e1a2a31995f2ecba82aaa8bd19ec for the web app and both functions. It retains the dynamic sample trial and corrects the public storage, model-transfer and no-send disclosures without changing the product flow.

Testing

No CI test calls Bedrock. Both runners accept an agent_factory, so tests inject fakes that answer well, answer badly, raise or hang. Storage tests use a fake S3 that enforces If-None-Match and If-Match. The suites cover tool schemas, tools-only mode, the guard, trace pairing, fallbacks, the counter, caps, fail-closed reading and replay. Playwright checks the briefing card and the paste tab, using fixture responses wherever a model reply is needed, and CI adds ruff and an 85% branch coverage gate.

The deployed model is called by the production acceptance workflow, run by hand against the live URL. Runs for this release:

What is not built

  • No notice or email is sent. Approvals are recorded as a simulation, and IAM denies Amazon SES.
  • Bank feeds, mailbox and retailer sync, and receipt photo OCR are not connected.
  • Amazon Bedrock AgentCore and Bedrock Guardrails are not connected.
  • Briefing quality, extraction accuracy, latency and cost are unmeasured.
  • Independent human user acceptance testing is NOT_RUN.
  • The tools flag what needs review; they do not decide eligibility. EU Directive 2019/771 is a general reference only, and eligibility needs its own review.

Links

Top comments (2)

Collapse
 
reidmarlow profile image
Reid Marlow

Separating the calculation from the narrative guard is the right call here. Having plain Python compute dates and amounts while treating the model strictly as an indexer keeps hallucinated financial claims out of the output. Making the guard withhold entirely instead of attempting a patch or re-prompt is also clean, because repairing a tainted briefing in-flight usually introduces a second layer of drift.

Collapse
 
jo-do profile image
Jo Do

"The model reads and points, plain Python works out the dates and amounts, the household decides" is the right division of labor, and no write tools is the load-bearing choice. Dates and money are exactly where a confident model is worst - let code be certain about arithmetic and let the model be useful about judgment. The guarantee-deadline example is perfect: that is a calendar computation wearing a language problem's clothes.