Idempotency¶
The single most important word in production agents is once.
The model is allowed to retry. The side effect isn't. Tulip makes that distinction a one-keyword decision on the tool, enforced inside the ReAct loop. This is an SDK-specific primitive — none of LangChain / LangGraph / CrewAI / Strands ship it.
If you ever plan to run an agent that isolates, blocks, pages, emails, or writes, this is the most important single page on the docs site.
When to use idempotent=True¶
| Situation | idempotent=True? |
|---|---|
| Side-effecting tool with real-world cost (isolate a host, block an indicator, page on-call) | yes — always |
| Database write you can't trivially roll back | yes |
| External service that's already idempotent on its end | yes — the SDK dedupes the round-trip too |
| Read-only indicator lookup | no — re-reads are cheap, leave it to the model |
Tool that intentionally generates a new entity each call (e.g. mint_case_id) |
no — that breaks the contract |
How it works¶
Inside a single agent run, the SDK hashes the tool's
(name, arguments) tuple as the model emits each call. The first
call with a given key hits the function body and the result is
recorded. Every subsequent call with the same key short-circuits
to the cached response without invoking the body.
from tulip.tools import tool
@tool(idempotent=True)
def isolate_host(host_id: str, incident_id: str) -> dict:
"""Isolate a host from the network. Re-fires within a run return the cached receipt."""
return edr.isolate(host_id, incident_id)
The argument hash is the trust boundary:
- Same call: the model re-emits
isolate_host("WS-014", "INC-92")after seeing the receipt → cache hit, body skipped. - Different call: the model emits
isolate_host("WS-015", "INC-92")→ different key, body runs.
Dedup compares the raw arguments dict the model emitted, exactly as
it emitted it — a plain dict == dict equality on (tool_name,
arguments). There is no JSON canonicalization and no schema
normalization: defaults are not filled in before the comparison, so
a call that omits an optional argument and a call that passes that
argument's default value are treated as different keys and both fire
the body. (Dict equality is itself order-independent, so key order alone
won't break a match.)
Why this matters¶
Containment actions¶
The model that calls isolate_host twice in one run is more common
than you think. Sometimes it sees an ambiguous tool result and tries
again "to be sure". Sometimes the network glitches and the model
believes the call failed. Without idempotency, you flap the host's
network state twice and the on-call gets paged for the duplicate.
@tool(idempotent=True)
def isolate_host(host_id: str, incident_id: str) -> dict:
return edr.isolate(host_id, incident_id)
The host gets isolated once. Always.
Outbound side-effects¶
page_oncall, block_indicator, open_case, slack_alert — anything
that touches a human or a downstream system. One and done.
Database writes you can't roll back¶
Insert into an audit table, append to a Kafka topic, sign a JWT — operations where retrying isn't free. Idempotent tools turn the "exactly once" problem into a "not-our-problem-after-the-first-call" guarantee.
Replays after checkpoint resume¶
When a checkpointer resumes a stalled run, the model may decide to
re-issue tool calls it's already seen. Idempotent tools see the
cache pre-populated from the checkpoint and skip the side effect on
replay. (This requires tool_executions to be restored from the
checkpoint; the SDK's native checkpointers handle
it.)
What it is not¶
| Concept | Idempotency is… | Idempotency is not… |
|---|---|---|
| Scope | within a single agent run | cross-run — restart and the cache is gone (use a checkpointer) |
| Failure | one fire per identical call | retry — if the body raises, the exception propagates as the cached "result" |
| Boundary | per-agent | network — two different agents both calling isolate_host(h, i) each fire once |
If you need cross-run idempotency, configure a checkpointer + an idempotent server-side endpoint. The combo gives you "the side effect runs at most once across all replays of all agents".
Practical recipe — containment approval¶
A canonical multi-agent idempotency shape: an agent (or three of them, debating) loops over a containment decision, then writes once.
@tool(idempotent=True)
def isolate_host(host_id: str, incident_id: str) -> dict:
return edr.isolate(host_id, incident_id)
@tool(idempotent=True)
def page_oncall(incident_id: str, summary: str) -> str:
return pager.notify(team="soc", subject=f"INC {incident_id}", body=summary)
The agent can iterate ten times reasoning about whether to contain. The host gets isolated once. The on-call gets paged once. The model can fail mid-run and a checkpointer-backed resume re-issues the same calls; the side effects still fire exactly once.
Common gotchas¶
| Symptom | Likely cause |
|---|---|
Tool re-fires despite idempotent=True |
Argument changed between calls. Check that the model isn't mutating host / incident ids between turns. |
| Idempotent cache survives across runs unexpectedly | It shouldn't — only the checkpointer persists state. If you're seeing this, you're loading state from a checkpoint and don't want to. |
| Body raised first time, cache returns the exception | This is by design — the failure is part of the "result" of the first call. The model sees the failure and can react. To re-attempt, the model must change an argument. |
Read-only lookup tagged idempotent=True |
Harmless but wasteful — the cache hit savings are negligible vs the read itself. Leave it off. |
Source and notebook¶
@tooldecorator with idempotency hook_find_matching_execution— where the dedup actually happens, in the ReAct loop's Execute node.notebook_07_agent_with_tools.py— walks through the@tooldecorator end-to-end (idempotency covered in the agent-loop walkthrough).
See also¶
- Tools — the full
@tooldecorator surface. - Checkpointers — durable runs where idempotency interacts with replay.