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:
- Send messages + tool definitions to the provider.
- If the response contains a
tool_call, execute the matching tool. - Append the tool result as a
toolrole message. - 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.
withMaxStepsis 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.