Why did my AI agent send the email twice?

An agent crashes, retries or resumes right after a tool ran, and the tool runs again. Why it happens, and three honest ways to fix it in Python.

What happened

Your agent called a tool that changes the outside world: it sent an email, charged a card, created a ticket. Then something interrupted it before it recorded that the tool had finished: the process crashed, a worker was redeployed, a request timed out, or a queue delivered the same job again. When the agent ran again, it saw no record of the email, so it sent it again.

This is not a model mistake. It is the normal behaviour of any system that retries work: it delivers at least once. Agent frameworks save their progress (a checkpoint, a message history, a run state) at certain moments. A tool that ran between two saves runs again on resume.

The three moments that matter

  1. Crash after the tool, before the save. The email went out; the framework never stored the result. On resume it runs again.
  2. Crash during the tool. Nobody knows whether the email went out. Running it again might send a second one; not running it might send none.
  3. Retry after a timeout. The tool succeeded but the answer was lost, so the caller retries it.

Three ways to fix it

1. Make the outside system drop the duplicate (idempotency keys)

Many payment and messaging APIs accept an Idempotency-Key header: the same key twice gives one action. The key must stay the same across crashes and retries, so it cannot be random per attempt. Derive it from the run and the action.

2. Write down the intent before acting, and the result after

Keep a small log: write "about to send email to bob" before the call and "done, result X" after. On resume, a finished action returns the stored result instead of running. An action with an intent but no result is in doubt: do not run it again automatically. Ask the provider for an authoritative answer (or ask a person); a read that finds nothing is not proof, because it can lag and the first request can still settle.

3. Move the side effect to a place that does not re-run

Some frameworks re-run specific code on resume (for example, LangGraph restarts the node that called interrupt()). Putting the side effect after that point avoids one of the causes, but not crashes.

With Call4Agents (Python, standard library only)

Call4Agents does the second fix for you, and passes a stable key for the first one:

import call4agents as c4a

c4a.init()                                  # a local SQLite run record

@c4a.effect                                 # runs once per (run, tool, exact arguments)
def send_email(to, body):
    return mailer.send(to=to, body=body)

with c4a.run("ticket-1234"):                # the id your agent resumes with
    send_email("bob@example.com", "Your refund is on its way")
    send_email("bob@example.com", "Your refund is on its way")   # a retry: returns the recorded result

If the process died during the send, the next call raises EffectInDoubt instead of sending again; call4agents doubt lists those actions and call4agents resolve records what a person found.

pip install call4agents
call4agents quickstart   # offline, no API keys: 2 emails without the kit, 1 with it

Related guides