Tool idempotency¶
A tool call that times out has not necessarily failed. The seat may be booked, the mail may be sent, the row may be written — the runtime only knows that nothing came back. Retrying is a second booking; not retrying leaves the run stuck. Neither is a decision the kit can make from the exception alone, so the tool declares what repeating it would do and the dispatcher holds a record of what already happened.
Declaring it¶
from tesserix_adk.core import Idempotency, IdempotencyPolicy
from tesserix_adk.tools import tool
@tool(idempotency=IdempotencyPolicy(Idempotency.READ_ONLY))
async def lookup_fare(flight: str) -> str:
"""Read the current fare.
Args:
flight: Which flight.
"""
...
@tool(idempotency=IdempotencyPolicy(Idempotency.EFFECTFUL, key_arguments=("flight",)))
async def book(flight: str, request_id: str) -> str:
"""Take a seat.
Args:
flight: Which flight.
request_id: Fresh on every attempt, which is why it is not a key argument.
"""
...
| Kind | What it means | Retried on failure | Deduplicated |
|---|---|---|---|
READ_ONLY |
Reads, changes nothing | Yes | No |
IDEMPOTENT |
Writes, but repeating lands the same state | Yes | Yes |
EFFECTFUL |
Repeating it is a second effect | No — see below | Yes |
key_arguments names the arguments that identify the effect. Everything else — a request id, a
trace header, a timestamp the model regenerated — is excluded, so a retry that renumbers itself
is still one key. A name that is not a parameter of the tool is refused at decoration rather
than at the call that matters.
A tool with no idempotency behaves exactly as it did before this existed. Nothing is claimed,
nothing is recorded, and the retry policy is unchanged.
The key¶
key = idempotency_key(
tenant="acme",
run_id="run_1",
tool="book",
arguments={"flight": "BA117", "request_id": "a"},
key_arguments=("flight",),
)
A SHA-256 over a canonical encoding of the tenant, the run, the tool name and the named
arguments. Canonical means: keys sorted, strings NFC-normalised, floats formatted so 2 and
2.0 agree, booleans kept distinct from the integers they would otherwise collapse into. Two
spellings of one payload are one key.
The arguments are hashed, never stored. An idempotency record holds a digest, a tenant and an
outcome string — it is not a second copy of the payload, and forget(tenant=...) erases a
tenant's records outright.
The tool receives the key on ToolContext.idempotency_key, which is what you pass to a
downstream that has an Idempotency-Key header of its own. Where no key can be derived — a
named key argument the model did not send — it is None.
The call id is deliberately not part of the key. The issue's scope named it; including it would give the two concurrent identical calls in one turn two different keys, and both would fire. Excluding it is what makes concurrent duplicates collapse.
The store¶
runner = AgentRunner(
provider=provider,
tools=tools,
idempotency=RedisIdempotencyStore(client, clock=clock),
idempotency_ttl_seconds=86_400,
)
MemoryIdempotencyStore is enough for one replica and for tests. RedisIdempotencyStore and
PostgresIdempotencyStore survive a restart and are visible to every worker; both claim a key
in a single server-side operation, because a read followed by a write is a window another
replica fits through. PostgresIdempotencyStore.ensure_schema() is a deployment's to call.
Anything satisfying the IdempotencyStore protocol works. IdempotencyStoreConformance in
tesserix_adk.testing is the suite your own implementation has to pass.
What the dispatcher does¶
Before the body runs, the dispatcher claims the key. If someone already recorded an outcome for
it, that outcome is returned to the agent and the body is not entered — a TOOL_DEDUPLICATED
event says so. If another caller holds the claim, this one waits for their answer rather than
executing alongside them.
When the body returns, its result is recorded against the key. When it fails, what happens next depends on the declaration:
READ_ONLYandIDEMPOTENT— the claim is released and the normal retry policy applies.EFFECTFUL— the claim is kept, no retry is attempted, and the run fails withIndeterminateOutcomeErrornaming the tool. The effect may have landed; the kit will not find out by doing it again.
The same applies when the key cannot be derived, or when the store cannot be reached. A store that is down does not read as permission.
The guarantee¶
At most one side effect per key within the retention window. This is versioned public API: it will not weaken without a major version and a changelog entry saying so.
Read what it does not say. Not exactly once — a call that fails without answering leaves the effect unknown, and the kit reports that rather than guessing. Not ever — a retry that arrives after the retention window has expired sees a free key, so choose a window longer than the longest replay you intend to support (the default is a day). And not across tenants — records are tenant-scoped, which is the isolation, not a limitation of it.
The honest failure is IndeterminateOutcomeError: the run stops, a human or an approval path
decides, and nothing is booked twice while they do.
Where it shows up¶
| Signal | Meaning |
|---|---|
RunEventKind.TOOL_DEDUPLICATED |
A recorded outcome answered this call |
RunEventKind.TOOL_INDETERMINATE |
An effect whose outcome nobody can state |
IndeterminateOutcomeError |
The typed error the run fails with |
examples/tool_idempotency.py runs all three.