A violating event order looks mundane: an agent emits tool_call(id=t1, refund, amount=20), the executor applies the refund, the ack is lost, the model is retried, and the retry emits tool_call(id=t2, refund, amount=20) because t2 is only a transport identifier. The external system now has two refunds for one user intent.
The invariant most chat-style implementations fail to preserve is:
I1 — External-effect uniqueness: for one accepted user-intent scope, a canonical tool intent produces at most one external side effect, independent of model retries, duplicate tool-call ids, stream aborts, worker crashes, or clock skew.
This article treats tool calls as a small commit protocol. It includes an executable simulator you can run against any model backend, including free tiers. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's operator-stated free model access to generate adversarial traces and its operator-stated free server option as a place to run the harness; the design below is portable and does not depend on those options remaining available.
Assumptions
- The model is not trusted for idempotency. It may regenerate equivalent calls with fresh ids after timeout, cancellation, compaction, or prompt revision.
- Tool execution is at-least-once from the model/runtime perspective. Some providers support idempotency keys; some do not.
- We control a small intent log in the agent process or orchestrator. We do not control the external API's internal retry behavior.
- A side effect is any operation that changes state outside the agent: charge, email, ticket, deploy, message, refund.
- Read-only lookups may bypass the commit path, but only if they are provably pure under retries.
Sequence model: intent before effect
sequenceDiagram
participant U as UserIntentScope
participant M as Model/Planner
participant L as IntentLog
participant X as Executor
participant E as ExternalAPI
U->>M: ask for outcome, not raw tool id
M->>L: propose canonical intent + semantic_key
L-->>M: accepted / duplicate / conflict
M->>X: dispatch accepted record with fence token
X->>E: execute with idempotency key if supported
E-->>X: ack / timeout / explicit failure
X->>L: mark ACKED, FAILED, or UNKNOWN
Note over L,X: UNKNOWN never blindly retries;
Note over L,X: it enters reconcile-or-compensate
State machine:
PROPOSED -> RECORDED -> DISPATCHED -> ACKED
| |
| -> UNKNOWN -> RECONCILING -> ACKED | COMPENSATED | DEAD_LETTER
-> REJECTED_DUPLICATE
-> REJECTED_CONFLICT
The important move is that the model proposes; the log decides. semantic_key is derived from canonicalized tool name, arguments, user-intent scope, and a monotonic attempt epoch. The tool-call id from the model is evidence, never identity.
Minimal runnable fixture
This Python fixture is stdlib-only. It is a simulator, not a production claim. Run it locally, in CI, or on any free server you already have access to.
# tool_commit_sim.py
from dataclasses import dataclass
from enum import Enum
import hashlib, json, random
class S(Enum):
PROPOSED=1; RECORDED=2; DISPATCHED=3; ACKED=4; UNKNOWN=5
RECONCILING=6; COMPENSATED=7; DEAD=8; REJ_DUP=9; REJ_CONFLICT=10
def canon(tool, args, scope):
return hashlib.sha256(json.dumps({"t":tool,"a":args,"s":scope}, sort_keys=True).encode()).hexdigest()
@dataclass
class Rec:
key: str; tool: str; args: dict; state: S=S.PROPOSED; fence: int=0
class External:
def __init__(self): self.effects=[]; self.known={}
def apply(self, rec, idem):
# external API honors idempotency only when caller supplies a stable key
if idem in self.known: return self.known[idem]
self.effects.append((rec.key, rec.tool, json.dumps(rec.args, sort_keys=True)))
self.known[idem] = "ok"
return "ok"
class System:
def __init__(self):
self.log={}; self.ext=External(); self.fence=0
def propose(self, model_id, tool, args, scope):
key=canon(tool,args,scope)
if key in self.log and self.log[key].state in {S.RECORDED,S.DISPATCHED,S.ACKED,S.UNKNOWN,S.RECONCILING}:
self.log[key].state = S.REJ_DUP if self.log[key].state==S.PROPOSED else self.log[key].state
return self.log[key], False
rec=Rec(key,tool,args,S.RECORDED); self.log[key]=rec; return rec, True
def dispatch(self, rec):
self.fence += 1; rec.fence=self.fence; rec.state=S.DISPATCHED
def finish_unknown(self, rec):
rec.state=S.UNKNOWN
def reconcile(self, rec, external_has_effect):
# In production this queries provider status by idempotency key or safe read.
rec.state = S.ACKED if external_has_effect else S.RECORDED
def execute(self, rec):
idem = rec.key # stable across model retries because it is not the model's tool-call id
return self.ext.apply(rec, idem)
def run(seed, inject):
rng=random.Random(seed); sys=System(); accepted=0
scope="user:42:checkout:7"
proposals=[("m1","refund",{"amount":20}),("m2","refund",{"amount":20})] # same intent, new model id
for mid,tool,args in proposals:
rec,ok=sys.propose(mid,tool,args,scope)
accepted += bool(ok)
if not ok: continue
sys.dispatch(rec)
if inject=="ack_loss" and mid=="m1":
sys.execute(rec); sys.finish_unknown(rec) # effect happened, ack lost
sys.reconcile(rec, external_has_effect=True) # recovery uses log, not model memory
elif inject=="crash_before_dispatch" and mid=="m1":
rec.state=S.RECORDED # crash before effect; safe retry
sys.dispatch(rec); sys.execute(rec)
else:
sys.execute(rec)
effects=len(sys.ext.effects)
return {"seed":seed,"inject":inject,"accepted":accepted,"effects":effects,"states":{r.key[:8]:r.state.name for r in sys.log.values()}}
if __name__=="__main__":
for inj in ["ack_loss","crash_before_dispatch","none"]:
print(run(7, inj))
Suggested check: for every injected failure class and every seed you choose, effects <= 1 for the same (tool,args,scope); any UNKNOWN must resolve through reconcile, not through another model guess. Treat N=2000 seeds as a starting denominator for your own run, not as a result claimed here. Acceptance rule: violations == 0 across the chosen seed set; any duplicate effect fails the canary even if latency is excellent.
Failure classes to inject
| Failure class | Event order | Common bug | Required behavior |
|---|---|---|---|
| Ack loss | effect applied, ack dropped, model retried | model creates fresh id and repeats | dedupe by canonical intent; reconcile UNKNOWN |
| Crash before dispatch | RECORDED then process dies | on restart, planner re-proposes from chat history | reload RECORDED intent; resume or safely reject |
| Reorder | two equivalent proposals race | last writer wins, both dispatch | single RECORDED record; loser becomes duplicate/conflict |
| Provider non-idempotent | retry reaches external twice | client assumed exactly-once | compensation path or dead-letter, not silent retry |
| Prompt revision | same user goal, changed wording | canonicalization too literal | version the canonicalizer; conflict instead of merge |
Testable properties
-
P1 uniqueness: count external effects grouped by
semantic_key; max must be one. -
P2 no blind retry: after
UNKNOWN, the next transition must beRECONCILING,COMPENSATED, orDEAD_LETTER, never freshDISPATCHEDfrom model memory. -
P3 pre-dispatch cancellation: cancellation before
DISPATCHEDmust be able to end inREJECTED_CONFLICTorRECORDEDwithout touching the external API. - P4 recovery from log: delete in-memory planner state between events; the intent log alone must decide reject/replay/compensate.
- P5 model independence: swap the model or prompt template; property outcomes must not change for the same canonical intent.
Where free model access helps without changing the protocol
Free model access is useful for breadth: generate paraphrases, stale ids, partial JSON, duplicate tool calls, contradictory arguments, and cancellation-like transcripts. A free server option is useful for repeatability: keep the harness running while you vary seeds and failure injectors. The limitation is that availability, quotas, model behavior, and duration are outside this design; treat them as replaceable capacity. If you want a low-friction place to try the same harness, MonkeyCode is one option I have access to, but the acceptance gate should follow you to any backend.
Tradeoffs
| Design choice | Latency | Throughput | Correctness | Cost | When it hurts |
|---|---|---|---|---|---|
| Trust model tool-call id | lowest | high | weak under retry | low | any timeout or compaction |
| Client-side canonical intent log | +small write | medium | strong for single orchestrator | storage | multi-writer without consensus |
| Provider idempotency key only | low | high | provider-dependent | request churn | providers with weak key semantics |
| Saga/compensation for UNKNOWN | higher | lower | strong with explicit reversal | operational complexity | non-compensable actions like sent email |
| Human approval gate | highest | low | strong for high blast radius | attention | high-volume low-risk calls |
Limitations and who should not use this
Do not use client-side dedupe as your only guard when several orchestrators can act for the same user intent; you need a shared log, leader, or database uniqueness constraint. Do not rely on reconcile for non-compensable side effects unless the provider exposes a safe status read. Do not put regulated money movement, medical actions, or destructive infrastructure changes behind a simulator-grade gate without a reviewed state store, access control, audit trail, and real provider contracts. Very low-volume internal scripts may not need this machinery; a cron job with a database unique constraint may be simpler and more honest.
Validation path
- Canonicalize one tool and one intent scope; write a property test that fails on duplicate effects.
- Add the simulator to CI with a fixed seed set and an explicit denominator.
- Inject
ack_loss,crash_before_dispatch,reorder, andprovider_non_idempotent. - Delete planner memory between events to prove recovery comes from the log.
- Only then increase model diversity, parallelism, or realtime routing.
Counterexample question to keep the design honest: if the order is DISPATCHED -> timeout -> crash -> model regenerates same intent as new id -> replay arrives before reconcile completes, should the system reject the late replay as a duplicate, replay it through the same semantic_key, or compensate the first effect? If your answer depends on the model remembering anything, the invariant is not yet real.
Top comments (0)