A feedback loop is more than a list of tool calls. The next model turn must be able to observe what the previous tool returned and decide whether to finish, correct an argument or request another action. Our model is a deterministic callback over copied history, so those decisions can be tested without a provider account.
Keep two budgets. A model-step budget counts every callback invocation, including a failed or malformed response. A call budget counts accepted tool attempts, including ones whose dispatcher result is denied or invalid_arguments. Counting failures prevents a model from spending unlimited attempts on requests that never succeed.
A final response is allowed after the last permitted tool call, provided a model step remains. Check final before rejecting a further call on budget grounds. Otherwise a useful tool result could arrive at the cap and the application would refuse the model’s valid conclusion.
Reserve each call ID before dispatch. Reusing it within this invocation stops with repeated_call rather than executing again. This is a small in-memory replay guard, not a durable exactly-once guarantee. Real effects across retries or process restarts need an explicit idempotency and persistence design.
calls = 1
max_calls = 1
response = {'kind': 'final', 'text': 'Finished'}
print(response['kind'] == 'final')
print(calls >= max_calls)Cancellation is checked immediately before and after obtaining a model response. That second check matters because the request may have been cancelled while the model was working. Once observed, cancellation wins before a tool runs, and no unpaired call is added to history.
Pass a copy of history to the model. A test callback that mutates its input must not rewrite previously recorded calls or results. Return a copied transcript too. These ownership choices make the trace trustworthy as a record of this simulated run, while natural-language answer quality still needs a separate evaluation.
history = [{'kind': 'call', 'id': 'c1'}, {'kind': 'tool_result', 'id': 'c1'}]
seen = {message['id'] for message in history if message['kind'] == 'call'}
print('c1' in seen, len(history))Test a tool-to-final exchange, a new call beyond budget, a repeated ID, cancellation triggered by the model callback, malformed output and a model exception. Assert status, counts, transcript shape and handler effects together. The budgets bound this synchronous simulation’s turns and accepted calls; they do not establish provider token costs, latency or safe execution of arbitrary real-world tools.