{"slug": "keeping-strands-agents-honest-in-a-household-money-app", "title": "Keeping Strands agents honest in a household money app", "summary": "A developer built Hestia, a household money app for the AWS Agents for Humans hackathon (Everyday Agents track), using two Strands Agents 1.53.0 agents that call Claude Haiku 4.5 on Amazon Bedrock. The review agent reads household records through four read-only Python tools and writes a briefing, while the reading agent converts pasted text into proposed records, with a local guard_narrative check withholding any briefing that overstates refunds, invents deadlines, or cites amounts not present in tool outputs.", "body_md": "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.\n\nAn 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.\n\nBoth 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.\n\nEach 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.\n\nThe tools are plain Python closures over the loaded private copy and the review date:\n\n| Tool | Reads | \n|---|---|\n| `review_repair_evidence(appliance_id)` | one appliance, its seller and any saved case | \n| `audit_subscriptions()` | trials, price changes and duplicates | \n| `check_receipts_and_utilities()` | missing receipts and bills above baseline | \n| `read_case_timeline()` | saved case status, next step and recent events | \n\nStrands builds each schema from the signature and docstring, so wrapping is one line. From `src/hestia/agents/household_agent.py`:\n\n``` php\ndef tool_functions(state: dict[str, Any], today: date) -> dict[str, Callable[..., str]]:\n    ...\n    def audit_subscriptions() -> str:\n        \"\"\"Inspect recorded recurring charges for trial end dates, price changes and overlaps.\n\n        Amounts are recorded monthly charges, not measured waste or savings.\n        \"\"\"\n        ...\n\ndef strands_tools(state: dict[str, Any], today: date) -> list[Any]:\n    \"\"\"Wrap the workspace callables as Strands tools (schemas come from signatures and docs).\"\"\"\n    from strands import tool\n\n    return [tool(func) for func in tool_functions(state, today).values()]\n```\n\nThe 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.\n\nTool 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.\n\nThe 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).\n\nA prompt is a request, not a control. So the code checks the answer.\n\n`guard_narrative` runs locally on the finished briefing. Any reason it returns withholds the whole briefing. It fires when the text:\n\nAn empty reply is withheld too.\n\nThe 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:\n\n```\nassert ha.guard_narrative(\"A €1,399.00 fee requires review.\", [\"1399 minor units\"]) == []\nassert ha.guard_narrative(\"A €14.00 fee requires review.\", outputs)  # 14 only appears in dates\nassert ha.guard_narrative(\"A €777.77 fee requires review.\", outputs)\n```\n\nA 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.\n\nIts 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.\n\nWhen 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.\n\nReasons 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.\n\nTyping 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`.\n\nThe 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.\n\nThe 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`.\n\nExtraction accuracy is unmeasured. The control is that a person confirms every fact.\n\nThe demo is a public link with no login, so limits live on the server: 3 reviews and 3 text readings per private copy, one shared daily limit of 200 reviews and readings per UTC day for both agents, 700 output tokens and a 20 second timeout. A review can make several model requests in its tool loop, so the daily limit counts reviews and readings, not requests. Cost per call is unmeasured.\n\nThe 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.\n\nOne 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.\n\nTwo 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.\n\nThe Lambda package installs `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 change set that removes or replaces a resource 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.\n\nThe live revision is `f8694a0e2e95df688e46e53c9a3e2e9da090e401` for the web app and both functions. This release moves the sample free trial (Fitness Stream Pro) to end three days after each copy opens, so the review names it on any day.\n\nNo 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.\n\nThe deployed model is called by the production acceptance workflow, run by hand against the live URL. Runs for this release:", "url": "https://wpnews.pro/news/keeping-strands-agents-honest-in-a-household-money-app", "canonical_source": "https://dev.to/efousekis/keeping-strands-agents-honest-in-a-household-money-app-17ld", "published_at": "2026-09-14 14:54:51+00:00", "updated_at": "2026-09-14 15:17:29.773569+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "large-language-models", "ai-safety", "developer-tools"], "entities": ["Hestia", "Strands Agents", "Claude Haiku 4.5", "Amazon Bedrock", "AWS Agents for Humans hackathon", "Lambda"], "alternates": {"html": "https://wpnews.pro/news/keeping-strands-agents-honest-in-a-household-money-app", "markdown": "https://wpnews.pro/news/keeping-strands-agents-honest-in-a-household-money-app.md", "text": "https://wpnews.pro/news/keeping-strands-agents-honest-in-a-household-money-app.txt", "jsonld": "https://wpnews.pro/news/keeping-strands-agents-honest-in-a-household-money-app.jsonld"}}