← Projects

Agent Failure Lab

What it is

Three-way matching is an accounts payable control: before an invoice is paid, it is checked against the purchase order and the goods receipt, then approved, rejected or escalated to a person. One of those outcomes moves money. I used it to ask a question: when a tool has a side effect and the run fails halfway, what protects you?

Ten cases, five attempts each, on synthetic data. 44 of 50 runs reached the correct outcome, 4 of 50 were false approvals, and the whole pass cost about four cents. It took two days. It is an experiment, not a product - nothing is deployed, and the output is the findings file with a trace behind every claim.

Agent failure lab: a hand-written tool-calling agent over three-way invoice matching, run fifty times A harness in one repository runs ten cases five times each. Before every attempt it restores a seed payment history, then starts an agent loop of about 130 lines with a hard limit of twelve steps. The loop sends the conversation and nine tool schemas to an OpenAI model, which answers with tool calls. Six lookup tools read five JSON fixture files; three terminal tools end the run, and only approve for payment writes anything. Every event is appended to a trace file and synced to disk, and a script reads the fifty traces into a per-case table. Results: 44 of 50 correct, 4 of 50 false approvals, 8 of 10 cases with full agreement. Finding one: in case 7 all five attempts made the same calls and received the same results, including a vendor lookup error, yet one escalated and four approved. Finding two: the model supplied the same idempotency key on every approval, but the payment tool wrote the key and never read it. After an injected crash the restart did not pay twice, only because the model chose to check for a prior payment, and it ended in reject, which is wrong. Agent failure lab A hand-written tool-calling agent over three-way invoice matching, run fifty times to count how it fails One repo, run locally 10 cases × 5 attempts Each case has an expected outcome cases.py · run.py State reset Seed file restored before each run payment_history.seed.json Trace file per run One line per event, synced to disk traces/run_*.jsonl Per-case table Agreement, correctness and cost analyse.py Harness Agent loop About 130 lines, no framework loop.py · MAX_STEPS = 12 Six lookups - read only get_invoice get_purchase_order get_goods_receipt get_vendor find_purchase_orders check_duplicate_payment Three terminal actions approve_for_payment reject escalate Only approve writes anything. Five JSON fixtures Stand in for an ERP payment_history.json Agent and tools OpenAI model Chooses which tool to call next gpt-5.6-luna · reasoning off Outside the repo Fifty runs - the numbers Correct outcome 44 / 50 False approvals 4 / 50 Cases at 5/5 agreement 8 / 10 Median steps per run 3 Median cost per run AUD 0.0008 Total spend AUD 0.04 Run on 19 September 2026. Left out on purpose No agent framework No retries, no self-correction prompt No validation layer Tool errors returned, never raised 1 2 3 4 5 6 7 Two findings Each has trace files behind it in the repo. 1 - Same evidence, different outcome Case 7: invoice, PO and receipt agree. The vendor lookup fails. Five attempts, the same five calls, byte-identical results get_vendor: {"error": "vendor_not_in_master_data"} Escalated - 1 of 5 escalate Approved - 4 of 5 approve_for_payment The error was in the transcript at the moment it approved. 2 - A stable key that guaranteed nothing Six approvals of the same invoice, fresh context each time. The model built the same idempotency key every time INV-001-SUP-1001 approve_for_payment Writes the key into the payment record. Never reads it. A key only protects if something on the write path checks it. Per case - correct outcomes out of five Case Expected Correct 1 · Exact match approve 5/5 2 · Quantity within tolerance approve 3/5 3 · Price over tolerance escalate 5/5 4 · No goods receipt escalate 5/5 5 · Duplicate invoice reject 5/5 6 · Partial delivery escalate 5/5 7 · Vendor lookup error escalate 1/5 8 · Currency mismatch escalate 5/5 9 · Missing PO number escalate 5/5 10 · Ambiguous PO escalate 5/5 Case 7 produced all four false approvals. Case 2 over-escalated twice. After an injected crash and restart Paid twice no Why not the model chose to check Restart ended in reject, which is wrong Nothing required that check, and the key played no part. What I would build differently Preconditions go in the tool, not the prompt. Actions with side effects need an already-done outcome. Reset state before every run, or the numbers mislead. Report agreement across runs, not one run's accuracy. Legend Harness and loop Data and traces Outside the repo One run Leads to Flow 1 run.py picks a case and an attempt, restores the seed payment history and starts the loop. 2 The loop sends the conversation and the nine tool schemas to the model. 3 The model answers with tool calls - typically four lookups in a single turn. 4 Each call is dispatched. Results go back to the model as data, and so do errors. 5 A terminal tool ends the run. Only approve_for_payment writes: one record in the payment history. 6 Every model call, tool call and result is appended to the run's trace file and synced to disk. 7 analyse.py reads the fifty traces and builds the per-case table.
One run of the agent failure lab, step by step A vertical path of one run. The harness picks a case and an attempt and restores the seed payment history. The agent loop sends the conversation and nine tool schemas to an OpenAI model, which answers with tool calls. The tools are dispatched and their results, including errors, go back as data, repeating for up to twelve steps. A terminal tool ends the run, and only approve for payment writes a record. Every event is appended to a trace file, and a script builds a per-case table from the traces. Across fifty runs: 44 correct, 4 false approvals, 8 of 10 cases with full agreement, a median of 3 steps, and about four cents in total. One run Repeated fifty times: ten cases, five attempts A case and an attempt Seed payment history restored first run.py · cases.py 1 Agent loop Sends the messages and nine tool schemas loop.py · about 130 lines 2 OpenAI model Answers with tool calls gpt-5.6-luna · reasoning off 3 Tools dispatched Results and errors go back as data repeats, up to 12 steps 4 Terminal tool ends the run Only approve writes: one payment record payment_history.json 5 Trace file Every event appended, synced to disk traces/run_*.jsonl 6 Per-case table Agreement, correctness and cost analyse.py Fifty runs - the numbers Correct outcome 44 / 50 False approvals 4 / 50 Cases at 5/5 agreement 8 / 10 Median steps per run 3 Median cost per run AUD 0.0008 Total spend AUD 0.04 Legend Harness and loop Data Tools Outside the repo One run
Two findings from fifty runs of the agent failure lab Finding one: in case 7 the invoice, purchase order and goods receipt agree but the vendor lookup returns an error. All five attempts made the same calls and received the same results; one escalated and four approved. Finding two: across six approvals of the same invoice the model built the same idempotency key every time, but the payment tool writes the key and never reads it. Per case, eight of ten cases were correct five times out of five; case 2 was correct three times and case 7 once. After an injected crash and restart the invoice was not paid twice, only because the model chose to check, and the restart ended in reject, which is wrong. Two findings Fifty runs, a trace file behind each claim 1 - Same evidence, different outcome Case 7: invoice, PO and receipt agree. The vendor lookup returns an error. Five attempts, same calls, same results vendor_not_in_master_data Escalated 1 of 5 Approved 4 of 5 The error was in view when it approved. 2 - A stable key that did nothing Six approvals of one invoice, fresh context. The model built the same key every time INV-001-SUP-1001 approve_for_payment Writes the key. Never reads it. A key only helps if the write path checks it. Correct outcomes out of five Case Expected Correct 1 · Exact match approve 5/5 2 · Qty within tolerance approve 3/5 3 · Price over tolerance escalate 5/5 4 · No goods receipt escalate 5/5 5 · Duplicate invoice reject 5/5 6 · Partial delivery escalate 5/5 7 · Vendor lookup error escalate 1/5 8 · Currency mismatch escalate 5/5 9 · Missing PO number escalate 5/5 10 · Ambiguous PO escalate 5/5 Case 7 produced all four false approvals. After an injected crash and restart Paid twice no Why not model chose to check Restart ended in reject - wrong Nothing required that check. What I would build differently Preconditions go in the tool, not the prompt. Add an already-done outcome for retries. Reset state before every run. Report agreement across runs, not accuracy.
Ten cases, five attempts each, every event written to a trace file. Four runs approved with a vendor error in front of them, and the idempotency key the model supplied was never checked by the tool that wrote it.

What I built

Decisions

Wrote the loop by hand, with no retries, no self-correction prompt and no validation layer.

Rejected: An agent framework that handles retries and correction.

Each of those would have hidden the failures I was trying to count.

Tool errors go back to the model as data.

Rejected: Raising the error and stopping the run.

What the model does with an error is the thing being measured. In one case it approved four times out of five with the error in front of it.

Approve, reject and escalate are tool calls.

Rejected: Reading the decision out of the model's text.

A model that says it would approve but never calls the tool is recorded as a failure, not an approval.

Five attempts per case, reported as agreement.

Rejected: One run per case, reported as accuracy.

On a single run the worst case would have looked like bad luck. The spread across attempts is the result.

What broke

My own test harness. One exploratory run wrote a payment into the history file, and the next fifty-run pass read it back as a duplicate. The agent rejected the first case five times out of five and it was right to - given what it could see. About nine of the fifteen misses in that pass were contamination, not model behaviour, and they looked exactly like model variance. The fix was eight lines: restore a seed file before every attempt. I kept the contaminated traces in the repo.

Stack

Python · OpenAI API (tool calling)

github.com/nalinka1/agent-lab