← Laragents 3 / 18

Quick start

A chat turn, end to end: a session, the history, the loop, an answer.

A session

use EduLazaro\Laragents\Models\ChatSession;

$session = ChatSession::create([
    'tenant_type' => $organization->getMorphClass(),
    'tenant_id'   => $organization->getKey(),
    'actor_type'  => $user->getMorphClass(),
    'actor_id'    => $user->getKey(),
    'title'       => 'New conversation',
]);

The tenant is who pays and whose data this conversation may see. The actor is who is talking. There is a third, sessionable, for what the conversation is about, and leaving it out is the common case: see Chat sessions.

A tool

Two methods and a schema. The model calls it by name() and picks it by description(), so the description is written for the model, not for whoever reads the code next:

use EduLazaro\Laragents\Tools\Tool;
use EduLazaro\Laragents\Tools\ToolContext;
use Illuminate\JsonSchema\JsonSchema;

class ListProperties extends Tool
{
    public function name(): string
    {
        return 'list_properties';
    }

    public function description(): string
    {
        return 'List the properties currently on the market for this office. '
             . 'Use it before answering anything about what is available.';
    }

    protected function schema(): array
    {
        return [
            'max_price' => JsonSchema::integer()->description('Upper bound in euros.'),
            'bedrooms'  => JsonSchema::integer()->description('Minimum bedrooms.'),
        ];
    }

    public function execute(array $args, ToolContext $context): array
    {
        $office = $context->require('office');

        $properties = $office->properties()
            ->when($args['max_price'] ?? null, fn ($q, $p) => $q->where('price', '<=', $p))
            ->when($args['bedrooms'] ?? null, fn ($q, $b) => $q->where('bedrooms', '>=', $b))
            ->limit(20)
            ->get(['id', 'reference', 'price', 'bedrooms']);

        return ['items' => $properties->toArray()];
    }
}

$args is what the model asked for and is untrusted. $context is what your app put there and is trusted. Never take the office from the arguments.

Register it once:

Laragents::tools([ListProperties::class]);

The turn

use EduLazaro\Laragents\AgentLoop;
use EduLazaro\Laragents\HistoryCompressor;
use EduLazaro\Laragents\Tools\ToolContext;
use EduLazaro\Laragents\Tools\ToolRegistry;

class ProcessMessage implements ShouldQueue
{
    public function handle(
        AgentLoop $loop,
        HistoryCompressor $compressor,
        ToolRegistry $tools,
    ): void {
        $session = ChatSession::find($this->sessionId);

        $response = $loop->run(
            messages: $compressor->buildHistory($session, $this->systemPrompt($session)),
            model: 'gpt-4.1-mini',
            session: $session,
            context: ToolContext::make([
                'organization' => $session->tenant,
                'user'         => $session->actor,
                'office'       => $this->office,
            ]),
            tools: $tools->definitions(),
        );
    }
}

Queued, because a turn is not one call: the model asks for a tool, gets the result and asks again, and that takes longer than a request should wait.

buildHistory() replays the conversation and summarises the older turns once the replay outgrows its budget. run() returns when the model answers in prose, and writes every turn to laragents_chat_messages on the way, so nothing else has to persist anything.

The system prompt

protected function systemPrompt(ChatSession $session): string
{
    $parts = [
        Rule::promptBlock($session),
        'You are the assistant of an estate agency. Be concise.',
        Memory::promptBlock($session),
    ];

    return implode("\n\n", array_filter($parts, fn ($p) => trim($p) !== ''));
}

Those two lines are what makes it remember. Without them the memory tools still work, the table still fills up, and the model is never told any of it, which is the sort of bug that looks like the feature merely being disappointing.

Rules go above your prompt and memories below it, and the order is not decorative: a directive buried under a wall of remembered context stops reading as a directive.

What you get back

$response->content;        // the answer
$response->totalTokens();  // what the whole turn cost, tools included
$response->finishReason;   // 'stop', or 'max_iterations' when the cap ran out
$response->wasTruncated(); // providers spell truncation two different ways

Check finishReason. A truncated reply arrives as ordinary content, cut mid-sentence or empty, with no exception raised anywhere.

Next

The loop explains the iteration cap and the guard that stops a model searching in circles. Memory is the part that makes it feel like a product rather than a demo.