Development

Verifying Agent Tool Calls: Tests That Catch a Doubled Refund

Antonio Evans7 min read

A split scene showing a developer's test harness on one side, with a tool call log and a refund ledger.
Key points

Key points

  1. 1Test the side effect, not the tool call: record the intent, replay the same call with the same idempotency key, and assert the external system shows exactly one write.
  2. 2Model writes with a unique operation key persisted at decision time, because the attempt ID changes on retry while the intended effect does not.
  3. 3Seed fixtures so each test starts with a known ledger, outbox, and mailbox, then assert row counts, not response strings.
  4. 4Any tool that moves money, sends a message, or changes account state needs a guard; pure read tools just need argument assertions.

Why the doubled refund happens at all

An agent decides to issue a refund, calls the payment tool, and the call commits. Then the connection drops before the response arrives. From the agent's perspective, success and failure look identical, so the framework retries, and the refund runs again. That is not a model bug. It is ordinary distributed systems behavior that the agent layer imported without the patterns that usually prevent it, as this analysis of agentic tool calling argues (TianPan.co).

There are three common triggers. A transient execution error where the tool partially ran, a confusing success message that reads like a failure (for example "0 rows updated" when the value was already correct), and a tool-call loop where the model cycles through the same call repeatedly (RunGuard). All three produce the same outcome: two ledger entries for one intent.

The webhook world solved this years ago, and the fix transfers directly. Helpers that make webhook handling safe, whether you send or receive, always come back to the same idea: when repeating an action produces the same outcome, the action is idempotent, and PUT versus POST is the classic illustration because POST implies a new side effect on every call (Hookdeck). A refund tool is a POST in disguise. Your eval suite exists to prove the disguise fails closed.

Durable execution engines help, but do not close the gap. One test rig killed a worker with SIGKILL between a tool's side effect and the moment the step result was journaled, and a mock payment API received two charges in 3 of 3 trials; passing a stable key to the payment call made the second request a no-op in 3 of 3 trials (NerdLevelTech). Journals protect completed steps. Nothing protects a step whose effect landed but whose result was never recorded.

Record the intent before the model acts

The single most useful design decision is separating intent identity from attempt identity. A run ID, trace ID, or tool-call ID usually changes when work is retried, so none of them can answer "did this refund already happen?" One intended refund can have five attempts and five traces while still having one operation key (AI Programming Manual).

Good keys come from the business decision, not the payload. refund:invoice_842:approval_17 is stable across retries of one approval and different for a second legitimate refund on the same invoice. Do not derive keys by hashing tool arguments alone, because two customers may legitimately receive the same template message, and one customer may legitimately receive it twice months apart.

The runtime should generate the key before asking the model to call the tool, then inject it into the tool context. The model supplies business arguments; the platform supplies identity. A small operation table makes that explicit:

CREATE TABLE tool_operations (
  operation_key   TEXT PRIMARY KEY,
  payload_hash    TEXT NOT NULL,
  status          TEXT NOT NULL CHECK (
    status IN ('in_progress','completed','needs_reconciliation')
  ),
  lease_until     TIMESTAMPTZ NOT NULL,
  result_json     JSONB,
  downstream_id   TEXT,
  updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

The distinction between "safe to try" and "unknown whether it happened" is the whole point. Collapsing both into failed is how duplicate effects get born (AI Programming Manual). Webhook receivers reached the same conclusion: dedupe on the event's unique ID before doing any work, store that ID durably, and inside the same transaction as the side effect apply a unique constraint, so a conflict is your signal to acknowledge and do nothing (Devlume). Your refund tool should behave like that receiver.

Fixture design: replay the same call, not a similar one

A fixture for duplicate processing has to be boring and exact. Four elements matter:

  1. A seeded identity. One customer, one invoice, one approved refund intent, all with fixed IDs. Tests that invent IDs at runtime cannot assert counts reliably.
  2. A fixed operation key. Not a fresh UUID per run. The point is to replay the identical key, and the identical arguments, twice.
  3. A clean effect surface. A sandbox ledger, an outbox table, and a captured email list, all keyed by the test run ID so cleanup is a single delete.
  4. A failure injector. A switch that makes the first call commit and then drop the response, which is the only way to reproduce the real failure rather than a fake one.

The mock, sandbox, live decision is about which layer you are trying to prove, not about realism. Mocks are fast and deterministic; sandboxes catch real schemas, auth, and state transitions; live scoped checks only when nothing else can represent the production surface (Hamming). Duplicate-write tests belong in the sandbox almost every time, because a mock that just returns 200 twice proves nothing about whether your unique constraint fires.

A practical fixture looks like this in a test file:

fixture = {
    "run_id": "eval_dupe_refund_014",
    "customer_id": "cus_fixture_07",
    "invoice_id": "in_842",
    "operation_key": "refund:in_842:approval_17",
    "amount_cents": 4200,
    "drop_first_response": True,
}

Then the test harness runs the agent or the tool wrapper twice with that fixture and inspects the ledger directly. If you want one API key across the models driving the agent and the model used to judge failures, that is what Quanta Inference (https://quantaintelligence.ai/inference) is for, but the assertions themselves should be plain code.

Assertions on side effects, with worked numbers

The assertion that matters is a count on the external system, not a string in a transcript. A passing transcript is useful evidence, not proof that the workflow succeeded; the ordinary failure is the agent saying "you're booked" while the system holds zero or two appointments (Hamming).

Take a refund of 4,200 cents, about $42. A single-intent run should leave these numbers in the sandbox:

SurfaceAfter one intent, one attemptAfter one intent, two attemptsVerdict
Refund ledger rows for in_8421must still be 1fail if 2
Sum of refund amounts4,200must still be 4,200fail if 8,400
Outbox rows (email to customer)1must still be 1fail if 2
tool_operations rows for the key1must still be 1fail if 2
Customer balance delta-4,200must still be -4,200fail if -8,400
Audit log entries marked refund_committed11 or 2 allowedinformational

That last row matters for triage. Audit entries may legitimately record two attempts, because attempts are real events. The ledger may not. Writing both assertions into the same test teaches reviewers the difference between "we tried twice" and "we paid twice", which is the bug you are hunting.

Webhook practice gives you a sanity check on the same numbers. The recommended verification for an idempotent receiver is that two deliveries with the same ID produce only one record, both requests receive HTTP 200, and the business rule runs once (Asaas docs). Apply that shape to your agent: two tool calls, one effect, both calls report success to the model so it stops retrying.

A decision rule for which side effects need a guard

Not every tool deserves the full operation table. Spend the guard where a duplicate costs money, trust, or compliance, and skip it where a repeat is harmless.

Tool typeExampleDuplicate costGuard
Money movementissue refund, capture payment, transferdirect financial loss, reconciliation workrequired: operation key plus downstream idempotency key
Outbound messagesemail, SMS, Slack post, webhookcustomer annoyance, support loadrequired: content-derived key or sent-log check
Record creationopen ticket, create order, append CRM noteduplicate tickets, inflated countsrequired: unique constraint on natural key, upsert semantics
State transitionmark paid, set status, assign ownerusually idempotent by naturecheap: absolute state writes, no guard beyond a test
Increment or counteradd loyalty points, decrement stocksilent drift, hard to auditrequired: derive from an event, never from arithmetic on retry
Pure readslookup customer, check availabilitynoneno guard: assert arguments only

Two refinements. First, prefer absolute state changes: SET status = 'paid' is retry-safe while balance += amount is not, and keep any dedup window longer than the sender's retry window, because a one-hour TTL against a 72-hour retry period protects only the first hour (Prismatic). Second, for multi-step workflows that reserve inventory, charge, and email, treat each step as its own guarded operation rather than wrapping the sequence in a retry, since a retried sequence re-enters steps that already committed (TianPan.co).

If you want the guard designed once for a line of business rather than per tool, Quanta Skills (https://quantaintelligence.ai/skills) packages these patterns as installable skills for coding agents, and an expert can be put on the job for a specific workflow.

Wiring the suite into CI and reading the failures

Run the duplicate-write suite on every change to prompts, tool schemas, and orchestration code, because all three can alter retry behavior without touching the tool. Gate merges on it. Keep the suite small; a dozen tools times three scenarios is plenty and finishes in minutes.

When a test fails, classify it before fixing it:

  • Two effects, both calls returned success. The tool has no deduplication. Add the operation key and constrain it.
  • Two effects, model retried after a misleading response. Make the tool return an explicit "already applied" success, which removes the model's reason to retry (RunGuard).
  • One effect, but the run hung. The lease expired and the status flipped to needs_reconciliation; reconcile against the downstream system before retrying, never infer success from an exception.
  • Zero effects and one success message. The worst case. Assert on external state, not on the agent's claim.

Signature and replay checks on inbound webhooks belong in the same suite if the agent's effects feed back through webhooks, and the Standard Webhooks spec is the reference for consistent signing conventions across providers (standard-webhooks.md), with the project's own overview describing the goals of security, interoperability, and reliability (Standard Webhooks).

One more habit: keep the fixtures and assertions as reviewable files, so a change to deduplication logic shows up as a diff in a test that a reviewer can actually read.

FAQ

FAQ

Should the eval call the agent or the tool directly?

Both, at different layers. Call the tool directly to prove the guard works under replay, which is fast and deterministic. Call the full agent at least once to prove it produces the right operation key and does not loop. The tool-level test catches the doubled refund; the agent-level test catches the wiring mistake that creates two keys for one intent.

What if the downstream payment provider has no idempotency support?

You cannot make an external effect exactly once from your side alone. Either pass a key to a provider that honors it, or reconcile before retrying: query the provider for an existing refund against the invoice and treat a match as success. A local database row alone cannot make a charge exactly once (AI Programming Manual).

How long should the deduplication record live?

Longer than every retry window that can reach you. Stripe-style providers keep idempotency keys safely pruned after 24 hours, while retries can span multiple days depending on the integration, so sizing the window to the shortest window you know about is a common trap (Prismatic). For refunds, keep the operation record indefinitely; the cost of a row is nothing next to a double payout.

Do I need a durable execution engine as well?

Not necessarily, but know what it buys. A journal prevents re-running completed steps after a crash and saves duplicate model calls. It does not cover the window between an effect landing and its result being recorded, which is why the operation key and downstream idempotency still matter (NerdLevelTech).

How do I test the confusing-success case where the tool did nothing wrong?

Make the tool return an explicit outcome the model can act on, such as "refund already exists, status completed," and assert the model does not call the tool again in the same run. Counting calls is a legitimate assertion here, because the goal is a specific conversation shape, not just a single ledger row (RunGuard).

Sources

Sources

  1. The Idempotency Problem in Agentic Tool Calling - TianPan.co
  2. AI agent idempotency design: safe retries and duplicate tool ...
  3. How to Implement Webhook Idempotency - Hookdeck
  4. Restate Durable Agents: Duplicate Tool Calls Tested (2026)
  5. Idempotent Tool Calls for AI Agents: Prevent Duplicate Side ...
  6. Webhook reliability: retries, idempotency, and the design ...
  7. Voice Agent Sandbox Testing for Tool Calls and Side Effects
  8. How to implement idempotency in Webhooks
  9. How Do I Make Webhook Processing Idempotent - Prismatic.io
  10. standard-webhooks.md - GitHub
  11. Standard Webhooks
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles