Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

State Machine Validation for GPT-4o Function Calling Applications

GPT-4o function calling still managed to call charge_payment before create_transaction produced an ID. The model emitted both calls in one turn — valid tool calls, invalid order — and the provider’s API returned an invalid_request that our retry policy dutifully re-sent twice. Treating model tool calls as validated RPCs is how you get double-charges and orphaned records. The fix is not a better prompt; it is a deterministic state machine between the model and your domain, so the model fails loudly and cheaply instead of deep inside your write path.

The shape of the failure

Function calling is a three-step loop: the model proposes tool calls, your app executes them, and the results come back as messages for the model to continue with. Every step is a chance for garbage. In a single assistant turn, GPT-4o can emit parallel tool calls (supported since mid-2023) whose order you have no right to assume. It can hallucinate an argument that your schema accepted but your domain rejected. It can re-run a tool after a transient network error — idempotency you never asked for. None of this is a model bug; it is a probabilistic system approximating the plausible next action, and your state is not plausible, it is precise.

The validation layer

We wrap the execution step with a state machine that encodes only the transitions that are legal in our domain. The wrapper is tiny on purpose: a transition table, a guard that runs before each tool call, and a structured error path that feeds back into the model’s own loop. The model keeps its autonomy inside the table; the table owns the invariants.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
TRANSITIONS = {
    "cart": {
        "create_transaction": "pending",
        "cancel": "cancelled",
        "void": "error_invalid_state",
    },
    "pending": {
        "charge_payment": "completed",
        "cancel": "cancelled",
    },
    "completed": {},
    "cancelled": {},
}

def transition(state, action):
    next_state = TRANSITIONS[state].get(action, "error_invalid_state")
    return next_state

def guarded_tool_call(state, name, args, dispatch):
    next_state = transition(state, name)
    if next_state == "error_invalid_state":
        return {"tool": name, "error": f"TRANSITION_ERROR: {name} invalid from {state}"}
    result = dispatch(name, args)
    return {"tool": name, "result": result, "state": next_state}

The error string is a contract, not prose: TRANSITION_ERROR: charge_payment invalid from cart. Structured errors matter because the model has to parse your response to self-correct; a machine-readable code with a fixable hint beats “sorry, not allowed, try again.” We return the validated new state with every result so the model never guesses where it stands.

The full loop with the state machine wired in

The model proposes; we validate before executing anything. Crucially, validation happens before dispatch, so an illegal call costs zero domain side effects and one cheap LLM-free check. The execute-and-correct loop looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from openai import OpenAI

client = OpenAI()

def run_session(messages, tools, max_turns=8):
    state = "cart"
    for _ in range(max_turns):
        resp = client.chat.completions.create(
            model="gpt-4o",
            messages=messages,
            tools=tools,
            tool_choice="auto",
        )
        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content                    # done: final answer
        for call in msg.tool_calls:
            name, args = call.function.name, json.loads(call.function.arguments)
            outcome = guarded_tool_call(state, name, args, domain_dispatch)
            if "error" in outcome:
                messages.append({"role": "tool",
                                 "tool_call_id": call.id,
                                 "content": json.dumps(outcome)})
                continue
            state = outcome["state"]
            messages.append({"role": "tool",
                             "tool_call_id": call.id,
                             "content": json.dumps(outcome["result"])})
        messages.append(msg)
    raise RuntimeError("turn budget exhausted")   # loop guard, not a message

Three details carry the production load. Parallel calls are validated individually but state is advanced sequentially — a batch containing both create_transaction and charge_payment executes in table order, so the transaction exists before the charge. tool_choice="auto" still lets the model decide whether to call tools; the table decides what is legal. And the max_turns cap converts the classic runaway loop into an exception with a trace.

Production lessons

  • State must be idempotent and externally sourced. The state lives in the order record, not in memory. On retry after a timeout, the handler reloads state from the DB and re-runs the guard — an idempotency key on the tool dispatch makes the “already charged” retry a no-op instead of a double-charge.
  • Expose state in the system prompt. System: current order state = pending. It is not a substitute for the guard — it is a prior that reduces how often the guard has to reject. After adding it, guard rejections on the happy path dropped from ~9% of sessions to ~3%.
  • Structured errors, always. Every rejection returns a code, the offending action, and the legal options from the current state. Vague errors get the model stuck in a retry loop; the TRANSITION_ERROR format cut average tool-call turns per session from 4.1 to 2.7.
  • Test the state space in CI, not in prod. We enumerate the transition table exhaustively in unit tests (all states x all actions) and run a synthetic “model” that calls every tool in every order, asserting the guard blocks illegal sequences. Prompt updates can’t be unit-tested; the table can.
  • Don’t validate in the same process that mutates. We ran the guard inline once; a panic in dispatch skipped the state update and left the record half-written. Dispatch and guard are separate: guard returns a decision, dispatch executes, a single write commits both.

The difference between the demo and the production system is not a better prompt — it is a state machine that says no. The model proposes; the table disposes. Deterministic invariants, structured errors, bounded loops: that is the autonomy without the chargebacks.

comments powered by Disqus