Implementing the tool-calling loop with an LLM in plain PHP
I have basic chat completions working against an LLM endpoint from PHP. Now I want function calling, where the model can ask to invoke one of my tools, I run it, feed the result back, and let the model continue until it produces a final answer. The single request part is easy. The loop is what I keep getting wrong.
Specifically: how do you structure the message array as the conversation grows, how do you detect that the model wants a tool versus a final answer, and how do you guard against an infinite loop where the model keeps calling tools forever? Looking for the control flow, not a library.
The loop is: send messages plus your tool schemas, inspect the response, and branch. If the response contains tool calls, you append the assistant message verbatim, then for each requested call you execute it and append a tool-result message referencing that call id, then loop again. If the response has no tool calls, that is your final answer and you break. The key is that you must append the assistant message that requested the tools before you append the results, or the model loses the thread.
For the infinite loop guard, just cap iterations. A simple counter that breaks after, say, eight rounds and returns whatever you have, plus a log so you can see when you hit the cap. In practice a well-scoped toolset rarely needs more than three or four rounds, so hitting eight usually means a tool is returning something the model cannot make progress on. The cap turns a hang into a visible, debuggable event.
Sketch of the control flow:
The ordering point was my exact bug. I was running the tools and appending only the results, dropping the assistant message that contained the tool_calls, so the next turn the model had results with no record of having asked for them and got confused. Appending the assistant message first fixed it immediately. The iteration cap as a thrown exception rather than a silent return is also a good call, thanks.
One hardening note: validate and sandbox the tool arguments before you execute. The model will occasionally hallucinate an argument shape or pass a value out of range, and json_decode giving you null on malformed arguments should be handled, not assumed away. Return a structured error back as the tool result instead of throwing, and the model will usually correct itself on the next round. Treat tool inputs as untrusted just like user input.
```php blocks are runnable.