Laravel AI SDK: Tool-Calling Agents with Conversation Persistence
#laravel #ai #agents #llm

Laravel AI SDK: Tool-Calling Agents with Conversation Persistence

3 min read Mohamed Said Mohamed Said

Building Tool-Calling AI Agents in Laravel

The Laravel AI SDK (prism-php/prism, or the first-party laravel/ai package landing in Laravel 13) gives you a clean abstraction over LLM providers. The interesting engineering challenge is not the API call itself — it is making agents reliable: persisting conversation turns, enforcing structured output, and keeping tool execution auditable.

This article focuses on those three concerns with concrete, production-ready patterns.


Persisting Conversation History

Every multi-turn agent needs a conversation store. A simple agent_conversations table works well:

Schema::create('agent_conversations', function (Blueprint $table) {
    $table->ulid('id')->primary();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('agent');
    $table->json('messages')->default('[]');
    $table->timestamps();
});

The messages column stores the raw message array that the LLM expects, so replaying or resuming a conversation is trivial.

final class ConversationRepository
{
    public function append(AgentConversation $conv, array $newMessages): void
    {
        $conv->messages = array_merge($conv->messages, $newMessages);
        $conv->save();
    }

    public function forUser(int $userId, string $agent): AgentConversation
    {
        return AgentConversation::firstOrCreate(
            ['user_id' => $userId, 'agent' => $agent],
            ['messages' => []]
        );
    }
}

Registering Tools

Tools are plain PHP callables with a schema. Keep each tool in its own class so it can be unit-tested independently.

final class GetOrderStatusTool
{
    public string $name = 'get_order_status';
    public string $description = 'Returns the current status of an order by ID.';

    public function parameters(): array
    {
        return [
            'order_id' => ['type' => 'string', 'description' => 'The order UUID'],
        ];
    }

    public function __invoke(string $order_id): string
    {
        $order = Order::findOrFail($order_id);
        return json_encode([
            'status'      => $order->status->value,
            'updated_at'  => $order->updated_at->toIso8601String(),
        ]);
    }
}

Bind all tools through the service container so the agent class stays slim:

$this->app->tag([
    GetOrderStatusTool::class,
    CancelOrderTool::class,
], 'agent.tools.order');

The Agent Loop

A tool-calling agent runs in a loop until the model stops requesting tools or a step limit is reached.

final class OrderSupportAgent
{
    public function __construct(
        private readonly ConversationRepository $repo,
        private readonly PrismClient $prism,
        private readonly iterable $tools,
    ) {}

    public function handle(int $userId, string $userMessage): string
    {
        $conv = $this->repo->forUser($userId, 'order-support');

        $this->repo->append($conv, [['role' => 'user', 'content' => $userMessage]]);

        $steps = 0;
        do {
            $response = $this->prism->chat(
                model: 'gpt-4o-mini',
                messages: $conv->messages,
                tools: $this->tools,
            );

            $this->repo->append($conv, $response->newMessages());

            if ($response->finishReason() === 'tool_calls') {
                $toolResults = $this->executeTools($response->toolCalls());
                $this->repo->append($conv, $toolResults);
            }

            $steps++;
        } while ($response->finishReason() === 'tool_calls' && $steps < 5);

        return $response->text();
    }

    private function executeTools(array $calls): array { /* ... */ }
}

The $steps < 5 guard prevents runaway loops — a must in production.


Enforcing Structured Output

When you need machine-readable responses (not just prose), use a typed DTO and validate the model output against it:

final readonly class RefundDecision
{
    public function __construct(
        public bool   $approved,
        public string $reason,
        public ?float $amount,
    ) {}

    public static function fromArray(array $data): self
    {
        return new self(
            approved: (bool) ($data['approved'] ?? false),
            reason:   (string) ($data['reason'] ?? ''),
            amount:   isset($data['amount']) ? (float) $data['amount'] : null,
        );
    }
}

Request JSON mode from the provider and decode into the DTO immediately. If decoding fails, throw a domain exception — never let a malformed LLM response silently corrupt downstream state.


Key Takeaways

  • Persist messages as a JSON column keyed by user + agent; replay is free.
  • One tool = one class: testable, taggable, and swappable via the container.
  • Always cap the agent loop with a step limit to prevent infinite tool-call cycles.
  • Decode LLM output into typed DTOs immediately; validate at the boundary.
  • Log every tool invocation with its input/output for debugging and auditing.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why store messages as JSON rather than individual rows?
LLM APIs expect the full message array on every request. A single JSON column lets you load and append the history in one query without reassembling rows, which keeps the hot path simple and fast.
Q02 How do you prevent a tool-calling agent from looping indefinitely?
Track the number of tool-call iterations and break the loop once a configurable maximum (e.g. 5) is reached. Return a graceful fallback message to the user rather than throwing an exception, so the conversation remains usable.
Q03 Can I unit-test individual tools without hitting the LLM?
Yes. Because each tool is a plain invokable class, you can instantiate it directly in a Pest test, pass mock arguments, and assert the returned JSON string — no HTTP call required.

Continue reading

More Articles

View all