Laravel AI SDK: Tool-Calling Agents and Conversation Persistence
#laravel #ai #agents #prism

Laravel AI SDK: Tool-Calling Agents and Conversation Persistence

4 min read Mohamed Said Mohamed Said

Why Tool-Calling Beats Prompt Engineering Alone

Large language models are good at reasoning but bad at side effects. Tool-calling (function calling) gives the model a typed contract: it decides when to call a tool, your application decides how. The result is an agent loop that is auditable, testable, and safe to retry.

The Laravel AI SDK (Prism) provides a fluent interface over multiple providers. This article focuses on building a practical agent with persistent conversation history and real tool execution.


Defining Typed Tools

A tool is a plain PHP class implementing EchoedLabs\Prism\Contracts\Tool. You declare its name, description, and parameter schema — the SDK serialises this into the provider's function-calling format automatically.

use EchoedLabs\Prism\Contracts\Tool;
use EchoedLabs\Prism\Schema\StringSchema;
use EchoedLabs\Prism\Schema\ObjectSchema;

class GetOrderStatusTool implements Tool
{
    public function name(): string
    {
        return 'get_order_status';
    }

    public function description(): string
    {
        return 'Returns the current fulfilment status for an order ID.';
    }

    public function parameters(): ObjectSchema
    {
        return new ObjectSchema(
            properties: [
                new StringSchema('order_id', 'The UUID of the order'),
            ],
            requiredFields: ['order_id'],
        );
    }

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

Keep handle() side-effect-free where possible. If it must write, wrap it in a database transaction and return a deterministic result so retries are safe.


The Agent Loop

Prism handles the loop for you, but understanding it matters for debugging:

  1. Send messages + tool definitions to the provider.
  2. If the response contains a tool_call, execute the matching tool.
  3. Append the tool result as a tool role message.
  4. Repeat until the model returns a plain text response.
use EchoedLabs\Prism\Prism;
use EchoedLabs\Prism\Enums\Provider;
use EchoedLabs\Prism\ValueObjects\Messages\UserMessage;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-3-5-sonnet-20241022')
    ->withMaxSteps(5)                    // hard cap on loop iterations
    ->withTools([new GetOrderStatusTool])
    ->withMessages($this->buildHistory($conversationId))
    ->withPrompt('What is the status of order 018f2a3b-...')
    ->generate();

withMaxSteps is your circuit breaker. Without it, a confused model can loop indefinitely.


Persisting Conversation History

Stateless HTTP means you must serialise and restore the message thread on every request. Store messages in a conversation_messages table:

Schema::create('conversation_messages', function (Blueprint $table) {
    $table->id();
    $table->ulid('conversation_id')->index();
    $table->string('role');          // user | assistant | tool
    $table->text('content');
    $table->json('tool_calls')->nullable();
    $table->string('tool_call_id')->nullable();
    $table->timestamps();
});

Rehydrate with a repository:

class ConversationRepository
{
    public function messages(string $conversationId): array
    {
        return ConversationMessage::where('conversation_id', $conversationId)
            ->orderBy('id')
            ->get()
            ->map(fn ($row) => MessageFactory::fromRow($row))
            ->all();
    }

    public function append(string $conversationId, array $messages): void
    {
        foreach ($messages as $message) {
            ConversationMessage::create([
                'conversation_id' => $conversationId,
                'role'            => $message->role->value,
                'content'         => $message->content,
                'tool_calls'      => $message->toolCalls ?? null,
                'tool_call_id'    => $message->toolCallId ?? null,
            ]);
        }
    }
}

After generate(), persist $response->messages — Prism returns the full updated thread including intermediate tool messages.


Handling Failures Gracefully

Tool execution can fail. Return a structured error string rather than throwing — the model can reason about it and ask the user for clarification:

public function handle(string $order_id): string
{
    try {
        $order = Order::findOrFail($order_id);
        return json_encode(['status' => $order->status->label()]);
    } catch (ModelNotFoundException) {
        return json_encode(['error' => "Order {$order_id} not found."]);
    }
}

This keeps the agent loop alive and produces a user-friendly response instead of a 500.


Key Takeaways

  • Typed tool classes give you IDE support, unit-testability, and a clean separation between AI reasoning and application logic.
  • withMaxSteps is non-negotiable in production — always cap the loop.
  • Persist the full message thread including tool call/result pairs; partial history breaks the model's context.
  • Return errors as JSON strings from tools rather than throwing exceptions to keep the agent loop alive.
  • Idempotent tool handlers make retries safe and simplify observability.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use the Laravel AI SDK (Prism) with OpenAI and Anthropic interchangeably?
Yes. Prism abstracts provider differences behind a unified interface. Swap `Provider::Anthropic` for `Provider::OpenAI` and the tool-calling serialisation is handled automatically, though model names differ per provider.
Q02 How do I prevent the agent from calling tools in an infinite loop?
Pass `withMaxSteps(n)` to the Prism builder. This hard-caps the number of tool-call/response iterations. A value of 5–10 covers most real-world tasks while protecting against runaway loops.
Q03 Should tool handlers be synchronous or can they dispatch jobs?
Keep tool handlers synchronous. The model is waiting for a result to continue reasoning. If the underlying work is slow, optimise the query or cache the result — dispatching a job and returning a pending status usually confuses the model's next step.

Continue reading

More Articles

View all