Agentic Distributed Systems

The Model Retries Because It Cannot See the Result

An agent calls send_email. The result never reaches the model — the call timed out, the stream broke, the process restarted. On the next turn the model sees a tool call with no result and does the natural thing: it calls it again. This is exactly the timeout-ambiguity case, with one addition that makes it worse: the party deciding to retry is non-deterministic and may retry with different arguments.

▶ Run the lab

The question this answers

The question

A side-effecting tool may have executed. How do I make the retry safe when the thing retrying is a model?

The guarantee — the property claimed, and its scope

With a caller-generated key attached to each tool invocation and a deduplicating handler, the guarantee is: at most one execution of the effect per key, and every repeat returns the original recorded result. That is not exactly-once delivery — the call may still arrive any number of times — it is exactly-once *effect*, which is the only version obtainable and the only version needed.

Everything below is bought to hold this sentence. "Strongly consistent" with no scope attached is a slogan, not a guarantee — read what it actually covers, and what it explicitly does not.

What a node knows — observation versus inference

The orchestrator knows it emitted a tool call and whether it received a response. The model knows strictly less: it knows only what is in its context, so a tool call with no result is indistinguishable from a tool call that was never made. Neither knows whether the effect occurred. The tool service is the only party that knows, and the entire design consists of giving it a way to tell you — a key it can look up.

A node knows its own state and the messages that arrived. Everything else is inference from evidence that was already stale. "B has not replied in five seconds" is knowledge; "B is down" is a decision — and usually the bug.

What guarantee?What does a node know?How does it work?What can fail?How does it fail?Where is coordination?What holds under failure?How does it recover?How would you know?What is the simpler thing?
idempotencyretriestool callsside effectsduplicates

This is `[[timeout-ambiguity]]`, and you should read it that way

The founding result of this domain is that a timeout tells you nothing about whether the work happened. [[timeout-ambiguity]] enumerates the five realities behind one observation, and three of them involve the work having completed. Nothing about that changes when the caller is an agent. The tool may have run.

What changes is *who decides to retry*, and how. In an ordinary service, retry is a policy: a library, a config value, a number of attempts, a backoff. It is deterministic, auditable and bounded. In an agent, retry is an *inference* — the model looks at a context in which a tool call has no result, concludes that it should try again, and emits a new call. Nobody configured this. It is not in the retry policy. It happens in the model’s output, which is one layer above every mechanism you built to control retries.

And the retry may not be identical. A retry library re-sends the same bytes. A model regenerates the call, and may phrase the arguments differently: "Subject: Order confirmed" becomes "Subject: Your order is confirmed". This single fact kills the most tempting deduplication scheme, and it is the reason this lesson exists as its own topic rather than as a paragraph in the idempotency module.

One email sent, one email the model believes was never sentprotocol
OrchestratorTool serviceModel providersend_email: deliveredsend_email200 {"id":"msg_88"}: sent, never arrives — dropped in flight200 {"id":"msg_88"}dropped — never arrivescontext WITHOUT a tool result: deliveredcontext WITHOUT a tool resultsend_email (again): deliveredsend_email (again)emit call send_email(...) (decide) at t=0emit call send_email(...)email actually sent (write) at t=3email actually sentdeadline expires — no result (decide) at t=8deadline expires — no resultno result in context → call send_email again (decide) at t=12no result in context → call send_email againsecond email sent (write) at t=15second email sentt=0time →t=15
delivereddelayed (dashed, long)duplicated (×2)dropped — stops short, never arriveswritedecide
The model is not malfunctioning. Given a context containing a call and no result, calling again is the correct inference from the information available to it. The bug is that the information available to it is wrong — and it is wrong because the orchestrator had nowhere to record what it did not know.

Why argument hashing fails

The first instinct is to deduplicate on the arguments: hash the tool name plus the payload and reject a repeat. It is cheap, it needs no changes to the model, and it is wrong in both directions.

False negatives. The model rephrases on the retry, the hash differs, and the duplicate sails through. Because rephrasing is the *normal* behaviour of a regenerated call, this failure is not an edge case — it is the common case for anything containing free text.

False positives. Two legitimately distinct calls have identical arguments. An agent processing a batch creates two tickets with the same title, sends the same reminder to the same person on two different days, or appends the same line twice on purpose. Argument hashing refuses the second, and the failure looks like a tool that "sometimes does not work".

The fix is to take identity from where the call *came from* rather than what it says. A key of (workflow_id, step_index, tool_name) survives rephrasing — the retry is the same step — and distinguishes legitimate repeats, which are different steps. Add a monotonic attempt discriminator only where a genuine re-execution is intended, and make that explicit rather than accidental.

SchemeSurvives a rephrased retry?Allows legitimate repeats?Verdict
Hash of tool name + argumentsprotocolNo — the hash changesNo — identical calls are refusedWrong in both directions. Do not use for effects.
Provider-assigned tool_call_idtypicalNo — a regenerated call gets a new idYesUseful for correlating a response to a request; useless as an effect key.
Random uuid per invocationprotocolNo — new uuid each timeYesDeduplicates nothing. Common, and gives a false sense of safety.
(workflow_id, step_index, tool_name)protocolYes — the step is the sameYes — a different step gets a different keyThe one that works. Derive it in code, never from model output.
Four ways to identify a tool call

Write the intent before you make the call

The mechanism has one non-obvious ordering requirement, and it is the entire difference between a system that can recover and one that cannot.

Persist the intent to call before making the call. Write {key, tool, arguments, status: "intended"} to durable storage, *then* invoke, *then* write the outcome. A crash between the first write and the second leaves a record that says "this may have executed and I do not know" — which is precisely the state of the world, faithfully recorded. A crash with no such record leaves you unable to distinguish "never called" from "called and lost", and no amount of later cleverness recovers the difference.

This is the same discipline as write-ahead logging, and [[checkpoint-and-log]] is the general form. The cost is one durable write per effectful step, which for a workflow measured in seconds of model latency is negligible — and it is only needed for *effectful* tools, not for reads.

On the tool side, the handler stores the key with the result, in the same transaction as the effect where the effect is a database write. A repeat with a known key returns the stored result without re-executing. [[deduplication]] and [[idempotency-scope]] cover the mechanics and, importantly, the retention question — a key store that forgets after an hour provides no protection against a workflow resumed the next morning.

1async function callEffectfulTool(step: Step, tool: Tool) {
2 // identity comes from the call SITE, not the payload
3 const key = `${step.workflowId}:${step.index}:${tool.name}`
4
5 // 1. an existing record answers the question for us
6 const prior = await steps.find(key)
7 if (prior?.status === 'succeeded') return prior.result // resume, do not re-run
8 if (prior?.status === 'intended') {
9 // we crashed mid-call. ask the tool, do not guess.
10 const known = await tool.lookupByKey(key)
11 if (known) return await steps.complete(key, known)
12 }
13
14 // 2. record the intent BEFORE the effect can happen
15 await steps.put({ key, tool: tool.name, args: step.args, status: 'intended' })
16
17 // 3. the tool dedupes on the same key, so a repeat is harmless
18 const result = await tool.invoke(step.args, { idempotencyKey: key })
19
20 // 4. record the outcome
21 await steps.complete(key, result)
22 return result
23}
The ordering that makes recovery possible

Classify the tools; most of them do not need this

Applying keys and step logs to every tool is expensive and unnecessary. Four categories, and only one of them is genuinely hard.

Read-onlysearch, get_customer, list_files. Retry freely; a duplicate costs a little load. The overwhelming majority of tool calls are these, and they should stay cheap.

Naturally idempotent writesset_status(id, "closed"), PUT of a whole document, add_tag. Retry freely; repeating produces the same state. Designing tools into this shape is the cheapest possible fix and is under-used: set_status is safe where advance_status is not.

Effectful and keyablecharge_card, create_ticket, send_email where the provider supports an idempotency key or a client-supplied message id. Use the key. This is the idempotency-key mechanism from API Design, and it is a solved problem at that level.

Effectful and not keyable — a third-party endpoint with no key support and no way to query what it did. There is no way to make this safe from the outside, so the choices are: wrap it in your own ledger and accept a small unavoidable window, require human approval so the duplicate is caught by a person, or design the workflow so this call is the last step and a failure means the workflow simply stops. Naming this category honestly at design time is worth more than any mitigation, because it is the one that produces the incident.

Return the stored result, never a duplicate error

This is the detail most often got wrong, and it converts a working deduplication scheme into a runaway loop.

When a repeat arrives with a known key, the natural implementation returns 409 Conflict: duplicate request. For an ordinary client, correct. For an agent, this is fed back into the context as a tool result, and the model reads an error. Its trained response to an error is to fix and retry — perhaps rephrasing the arguments, which produces a new key if your keying is weak, or reporting to the user that the operation failed when it in fact succeeded.

So: a repeat must return the original success, indistinguishably. Same shape, same fields, same status. Add a deduplicated: true field for your own telemetry if you like, but the model’s view should be that the call succeeded and here is the result — because that is true. The email was sent.

The same principle governs errors generally: what you hand back to the model is not a log line, it is an instruction it will act on. tool-errors-retries-timeouts in Agentic Engineering is the treatment; the distributed-systems reason is that in this architecture the error channel and the control channel are the same channel, so an error message is effectively a command.

Key points

  • A model retries because it cannot see a result, and a missing result is indistinguishable to it from a call never made.
  • This is exactly the case in [[timeout-ambiguity]] — the tool may have run — with the retry decided by inference rather than by policy.
  • A regenerated retry may carry different arguments, which breaks argument-hash deduplication in both directions.
  • Identity must come from the call site: (workflow_id, step_index, tool_name), derived in code, never from model output.
  • Persist the intent to call before invoking, and the outcome after. A crash between them must leave a record saying "unknown".
  • Classify tools: read-only and naturally idempotent need nothing; effectful-and-keyable need a key; effectful-and-not-keyable need a human or a redesign.
  • A deduplicated repeat must return the original success, not a duplicate error — an error hands the model an instruction to try again.

The chain, answered

Every field here is required, which is why no lesson in this domain can recommend a design without naming what an operator sees when it fails, what survives the partition, what repairs it afterwards, and the simpler thing to consider first.

How it works
  • Derive an idempotency key at the moment the orchestrator decides to make the call, from workflow and step identity.
  • Look up the key. A succeeded record short-circuits the call and returns the stored result.
  • An intended record means a prior crash: query the tool by key to learn what actually happened, rather than assuming either outcome.
  • Write the intent durably, then invoke the tool passing the key.
  • The tool service checks the key, executes the effect at most once, and stores the result alongside the key in the same transaction as the effect.
  • Write the outcome durably and append the result to the model’s context.
  • On a repeat, the tool returns the stored result with the same shape as an original success.
What can fail at the boundary
  • The tool executes and the response is lost, leaving the orchestrator with an unresolved intent.
  • The orchestrator crashes between the intent write and the invocation.
  • The orchestrator crashes between the invocation and the outcome write — the case the intent record exists for.
  • The model regenerates a call with altered arguments, defeating any payload-derived identity.
  • The tool’s key store expires entries before a long-running workflow resumes, so a resumed step re-executes.
  • The effect and the key are stored in different systems and only one of them commits.
  • A subagent re-plans and issues a semantically equivalent call under a different step index, producing a duplicate with a legitimately different key.
How it fails — what an operator sees
  • Duplicate email or message: the recipient receives the same content twice within seconds. Both tool invocations logged 200. The operator sees no error and finds it only from the recipient.
  • Double charge: two ledger entries for one intent, with different provider transaction ids. Reconciliation against the payment provider is the only way this surfaces, typically days later.
  • Retry loop on a duplicate error: the tool returns 409 duplicate, the model treats it as failure, rephrases and retries. The observable is token spend climbing on a task that never completes, and a tool whose 4xx rate is rising with no upstream change.
  • Wrong report to the user: the agent tells the customer "I was unable to send the confirmation" after it was sent — because the model saw an error and reported honestly on the information it had.
  • Resurrected step after key expiry: a workflow paused overnight resumes and re-executes a step whose key the tool no longer remembers. The observable is a duplicate whose two executions are hours apart, which defeats any time-window-based dedup alerting.
  • Silent partial dedup: the effect is deduplicated but a secondary effect — a metric increment, an audit row, a webhook — is not, so the counts disagree with the ledger and nobody can explain the drift.
Where coordination is required
  • The key store is a coordination point shared by the orchestrator and the tool service, and it inherits all the properties of any shared store: it can be unavailable, and when it is, you must choose whether to proceed unsafely or stop.
  • Making the key check and the effect atomic is what actually delivers at-most-once. If the effect is a database write, the same transaction does it. If the effect is an outbound call to a third party, atomicity is unobtainable and a small window remains — which should be documented rather than papered over.
  • No coordination is needed for read-only or naturally idempotent tools, which is the strongest argument for designing tools into those shapes.
  • Across subagents, the key namespace must be shared, or two agents working the same task generate different keys for the same intent and the deduplication does nothing.
What still holds under failure
  • Effects already executed remain executed; there is no rollback, which makes compensation the only correction — [[compensation-is-not-rollback]].
  • An unresolved intent is a truthful record of an unknown outcome and can be resolved later by querying the tool, which is the whole return on writing it.
  • If the key store is unavailable, the safe behaviour is to refuse effectful calls rather than proceed without protection — an availability cost paid deliberately.
  • Read-only tools continue unaffected, so an agent can often still make progress on the non-effectful portion of its work.
How it recovers
  • Detect: alert on unresolved intents older than a threshold, and on the duplicate rate per key at the tool.
  • Contain: for a tool that cannot be keyed, gate it behind an approval so a person is the deduplication mechanism until the tool is fixed.
  • Recover: resolve each unresolved intent by querying the tool by key; re-drive only where the tool confirms nothing happened.
  • Reconcile: compare the orchestrator’s step log against the tool service’s effect log on a schedule. The delta is the duplicate-or-missing set and it is the only reliable detector for this class of bug, because it produces no errors.
  • Verify: for effects with external consequences, reconcile against the external system — the payment provider, the mail provider — rather than against your own record of calling it.
How you would know
  • Deduplication hit rate at the tool, per key prefix. A steady non-zero rate is healthy and proves the mechanism is engaged; zero usually means keys are unique per attempt and dedup is doing nothing.
  • Count of steps in intended state, and their age distribution — the direct measure of unresolved ambiguity in the system.
  • Duplicate-effect rate measured *at the external system* where possible, since that is the only place the truth lives.
  • Tool 4xx rate segmented by whether the response was a dedup conflict, which is the signature of the retry-loop failure mode.
  • Tokens per completed task, which rises sharply when a model is looping on an unresolvable tool result.
When it helps
  • Any tool with an external side effect: money, messages, tickets, provisioning, deployments, physical actions.
  • Any workflow that can be resumed after a crash, since resumption is a retry by another name.
  • Any system where a subagent may be dispatched twice, which includes every queue-driven agent with at-least-once delivery.
  • Long-running workflows with human pauses, where the gap between attempts exceeds any naive time-window deduplication.
When it hurts
  • Read-only tools, where keys and step logs add latency and code for no benefit whatsoever.
  • Prototypes with no real side effects, where the machinery slows down the learning loop that matters more.
  • When it produces false confidence: a key that is regenerated per attempt, or a key store with a short retention, looks like protection and provides none. An unprotected tool you know about is safer than a protected one you are wrong about.
Simpler alternatives
  • Design the tool to be naturally idempotent — set_status rather than advance_status, PUT rather than POST — which removes the problem instead of managing it.
  • Let the effect be claimed rather than performed: the agent writes a row saying "send this email", and a separate, ordinary, deterministic worker sends it exactly once. This moves the hard part into code that is easy to test — the transactional outbox shape.
  • Require human approval for the effectful step, converting an unsolvable duplicate problem into a review queue.
  • Make the effect reversible and reconcile afterwards, where the business can absorb the duplicate more cheaply than the machinery costs.
  • Restrict the agent to read-only tools and have it produce a plan a person or a deterministic system executes — often the right first version.

The result never reached the model, so it called the tool again

The result never reached the model, so it called the tool again
Timeout ambiguity, with one addition that makes it worse: the party deciding to retry is non-deterministic, and may retry with different arguments.
simplifiedAttempts per call come from the engine's truncated geometric series. The duplicate count assumes the tool is the only place an effect can happen and that keys are derived deterministically — both of which are design decisions you have to actually make.
attempts per call
1.13
total tool invocations
113
duplicated side effects
13
runs broken by a 409
0
What actually happened, per 100 calls
effects intended100
effects executed113.4
duplicate emails / charges / shipments13.4
With no key, the tool cannot tell a retry from a new request. Every retry is a second charge, a second email, a second shipment. The orchestrator knows it emitted a call and whether a response came back; the model knows strictly less — a tool call with no result is indistinguishable from one that was never made. Neither of them knows whether the effect occurred. The tool service is the only party that does, and the whole design consists of giving it a way to tell you.
Sort your tools first — only two classes need work
Read-onlyget_customer, search_docs
None. Retry freely.
Naturally idempotentset_status(order, "shipped")
None, if the write is genuinely a set and not an increment.
Effectful and keyablecharge_card, send_email, create_shipment
A caller-generated key, stored with the result, atomically with the effect.
Effectful and not keyablea partner API with no key parameter, a physical action
A human, a wrapper that records intent before calling, or a redesign. There is no third option.
Derive the key in code from (workflow, step, tool) rather than asking the model for one — a non-deterministic component cannot be the source of a value whose whole job is to be identical across retries.

What people believe, and what is true

Claim

The framework passes a tool_call_id, so calls are already deduplicated.

Reality

That id identifies one generated call. A retry is a *new* generated call with a new id. It correlates a response to a request; it cannot deduplicate an effect.

Claim

We hash the arguments, so identical calls are caught.

Reality

A regenerated retry usually is not identical, and two legitimate calls often are. Argument hashing fails in both directions and is worse than no scheme, because it is trusted.

Claim

Telling the model not to repeat tool calls will fix it.

Reality

The model is not disobeying. It has no result in its context, which is indistinguishable from never having called. You cannot instruct away missing information.

Claim

Returning a 409 on a duplicate is correct behaviour.

Reality

For a normal client, yes. For an agent, the error is fed back as an instruction and triggers exactly the retry you were preventing. Return the original success.

Claim

Exactly-once is achievable if the framework is good enough.

Reality

Exactly-once *delivery* is not obtainable over a network — [[exactly-once]]. Exactly-once *effect* is, via at-least-once delivery plus a deduplicating handler, and that distinction is the whole design.

Go deeper

Only the levels this lesson can honestly fill — a missing level is a claim nobody had.

Overview

If a tool result never reaches the model, the model calls the tool again — and the tool may already have run. Give every effectful call a key derived from the workflow step, dedupe on it at the tool, and return the original result on a repeat.

Practical

Sort your tools into read-only, naturally idempotent, effectful-and-keyable, and effectful-and-not-keyable. Only the last two need work, and the last one needs a human or a redesign. For the keyable ones: derive the key in code from (workflow, step, tool), write the intent before the call and the outcome after, make the tool store key-and-result atomically with the effect, and make sure a repeat returns a success rather than a conflict. Then check the key retention against your longest possible workflow pause.

Advanced

The deep structure is that the model’s context is a replica of the workflow state, updated asynchronously and lossily, and the retry decision is made from that replica. Framed that way it is a stale-read problem, and the general fix is the one this domain always gives: do not try to make the replica authoritative, make the *action* safe under a stale read. The step log is the authoritative copy; the context is a derived view; the key is what lets a duplicated action collapse into the original. This also explains why the problem gets worse with more agents rather than better — each subagent holds its own lossy replica of the same workflow state, and any two of them may independently conclude that a step still needs doing, which is why the key namespace must be shared and why [[multi-agent-coordination]] treats ownership rather than communication as the primary problem.

Apply it

Build it, then break it
  • 🔧 Classify every tool in one agent into the four categories, and list which ones currently have no protection.
  • 🔧 Implement intent-before-call for a single effectful step and demonstrate recovery by killing the process between the two writes.
Reason about this
  • A model provider has a 30-second outage. Five hundred in-flight agents lose their tool results. Describe the aggregate effect on the downstream tool services and on customers.
  • An agent is asked to create three tickets with the same title on purpose. Explain why argument-hash dedup fails and what your keying does instead.
Interview questions
  • 💬 An agent sends an email and the tool call times out. What happens next, and what stops the customer getting two?
  • 💬 Why is hashing the tool arguments a bad idempotency key for an agent specifically?
  • 💬 Your tool returns 409 on a duplicate. What does the model do with that, and why is it a problem?
  • 💬 A workflow pauses overnight for approval and resumes in the morning. What breaks in a naive deduplication scheme?