← Laragents 4 / 18

The loop

AgentLoop is the whole of it: call the model, run whatever tools it asks for, feed the results back, repeat until it answers in prose or the cap runs out.

$response = $loop->run(
    messages: $messages,
    model: 'gpt-4.1-mini',
    session: $session,
    context: $context,
    tools: $registry->definitions(),
    persist: true,
    capabilities: [],
);

Same loop for the chat and for autonomous agents. In the application this came from there were two executors, one written by copying the other, and the copy had quietly missed out on the PII redaction the original gained months later. Nothing failed, nothing logged, and the second one simply sent everything in the clear.

The iteration cap

'chat' => [
    'max_tool_iterations' => 7,
],

Not a nicety. A model that keeps reformulating the same failing search will loop until something times out, and every turn of that loop is a billed call. When the cap is reached the loop returns the last content it had with finishReason set to max_iterations, which is better than nothing and, more importantly, is not mistakable for a real answer.

The empty-streak guard

The cap stops the spiral, but it stops it in the worst possible way: seven calls spent and no answer. So there is something before it.

A model that gets an empty result reformulates and tries again. That is the right instinct once. After that it is a spiral, and the model will spend every iteration it is given rather than tell the user there is nothing there.

'chat' => [
    'empty_streak_after' => 2,
    'result_keys' => ['items', 'results', 'matches', 'passages', 'data'],
    'empty_streak_hint' => null,
],

After two consecutive empty results the loop appends a hint to the tool result telling the model to answer instead of searching again. It is appended, never substituted: the tool's own hint usually explains how to reformulate, and replacing it leaves the model with an order to stop and no idea what it was doing wrong.

How it decides a result is empty is worth knowing, because it decides what your tools should return. A package cannot know what your tools produce, so it reads the shape. A result carrying any of the result_keys is a search and gets judged. A result carrying none of them says nothing either way, and this matters more than it sounds: a tool that files a document neither found nor failed to find anything, so it must not reset the streak. One bookkeeping call between two dead searches would otherwise hide the spiral completely.

An error counts as nothing found. A model retrying a failing tool is the same spiral wearing a different hat.

Set empty_streak_after to 0 to turn it off. Write empty_streak_hint in the language your model answers in; :count is replaced with the streak.

Tool failures go back to the model

try {
    return $tool->execute($args, $context);
} catch (\Throwable $e) {
    return ['error' => $e->getMessage()];
}

Logged and returned, not thrown. The model can recover from {"error": "..."} by picking a different tool or asking the user something. It cannot recover from a stack trace, and neither can the person waiting for a reply.

An unknown tool name gets the same treatment for the same reason.

Results are truncated

'max_tool_result_chars' => 16000,

One unbounded result eats the whole context window and pushes the actual conversation out of it. The truncation is crude on purpose, a mb_substr and an ellipsis, because a tool that returns 200KB has a problem the loop cannot fix politely.

Four hooks

Everything app-specific is a protected method. Extend AgentLoop and override what you need; the loop itself should stay boring.

outbound($messages) Messages on their way to the provider.
inbound($content) The model's text on the way back, before it is shown or stored.
inboundToolCalls($calls) Tool arguments, restored before the tools run.
arguments($tool, $args, $context) Add caller data to every tool's arguments.
afterTool($tool, $result, $context) Inspect or annotate a result before the model sees it.

The first three are how redaction is wired, and the third one is the one that fails quietly if you get it wrong: skip it and your tools query the database for «AP_1», find nothing, and report no error.

What is persisted

With persist: true, which is the default, every turn is written to laragents_chat_messages as it happens: the assistant's prose, the assistant's tool calls, and each tool result with its tool_call_id.

The assistant turn goes in even when its content is empty. The tool results that follow refer to those call ids, and a provider rejects a result whose call is missing from the history.

Pass persist: false for a run whose history belongs to someone else, which is what a skill invoked from inside a conversation wants.

Two events

Event::listen(ToolInvoked::class, function (ToolInvoked $event) {
    Log::info("{$event->tool} took {$event->seconds}s");
});

InvokingTool before, ToolInvoked after with the result and the duration. Both observe only: returning something from a listener changes nothing. See Events.