A tool-using application needs to distinguish what the model proposes from what the application actually does. Start with a small response protocol that has two branches: a final text answer, or a request to call a named tool with arguments. The model does not execute the tool merely by mentioning its name.
Our exercises use ordinary JSON-shaped dictionaries so the mechanics are deterministic and independent of any provider. A final response contains kind and text. A call contains kind, id, name and arguments. These exact keys are a teaching contract, not a copy of an API’s message format. A real adapter must translate its provider’s documented format into the application’s chosen representation.
Reject missing, extra and wrongly typed fields deliberately. A string that looks like JSON is not an argument object. A whitespace-only call ID cannot correlate useful results. A valid name is preserved exactly; the parser should not guess that a misspelled tool refers to some other function.
The parser returns an independent copy. JSON arguments may contain nested lists or dictionaries, so copying only the outer response leaves aliases behind. A later mutation should not rewrite the evidence of what arrived at the boundary.
import copy
raw = {'kind': 'call', 'id': 'c1', 'name': 'read_note', 'arguments': {'ids': ['a']}}
accepted = copy.deepcopy(raw)
accepted['arguments']['ids'].append('b')
print(raw['arguments']['ids'])Call IDs and document IDs serve different purposes. The call ID links a tool result to one request. A document ID identifies content that a tool may read. Neither is automatically a path or a permission. Cross-turn reuse of a call ID is checked by the later loop, not by this stateless parser.
Likewise, a well-formed final response is still just text. It may be wrong. Envelope validation establishes the shape of data that the next layer can inspect; it does not verify a natural-language answer or grant access to any resource.
call = {'id': 'c1', 'arguments': {'id': 'n1'}}
result = {'id': call['id'], 'ok': True, 'value': {'id': call['arguments']['id']}}
print(result['id'], result['value']['id'])Build the parser and check both accepted branches, blank identities, a string argument field, extra keys and a nested-copy mutation. Keep these tests when a real model adapter is added. They pin an application-level interface that can remain stable even when provider message formats change.