DEV Community

Robin
Robin

Posted on

Treat Agent Tool Calls as a Commit Protocol, Not Chat Messages

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
Enter fullscreen mode Exit fullscreen mode

State machine:

PROPOSED -> RECORDED -> DISPATCHED -> ACKED
                 |           |
                 |           -> UNKNOWN -> RECONCILING -> ACKED | COMPENSATED | DEAD_LETTER
                 -> REJECTED_DUPLICATE
                 -> REJECTED_CONFLICT
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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 be RECONCILING, COMPENSATED, or DEAD_LETTER, never fresh DISPATCHED from model memory.
  • P3 pre-dispatch cancellation: cancellation before DISPATCHED must be able to end in REJECTED_CONFLICT or RECORDED without 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

  1. Canonicalize one tool and one intent scope; write a property test that fails on duplicate effects.
  2. Add the simulator to CI with a fixed seed set and an explicit denominator.
  3. Inject ack_loss, crash_before_dispatch, reorder, and provider_non_idempotent.
  4. Delete planner memory between events to prove recovery comes from the log.
  5. 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)