Landscape cutout
jeroeng.dev
phplaravelelasticsearchscoutai

Elasticsearch AI tool for Laravel Scout

For a while now I maintain an Elasticsearch engine for Laravel Scout called Explorer. Recently Laravel released an AI SDK with which you can write agents in PHP. As I was playing with that SDK I wondered whether it would be possible to give it access to my Elasticsearch instance to find relevant documents. Of course a way more advanced setup would use RAG, but Elastic seemingly put that behind a paywall. So I went with a (possibly less performant) way where my agent would just be able to query Elasticsearch itself via Laravel Scout.

Let me illustrate how I did this by adding an agent to the Explorer demo repository (it uses cartographers because I like maps :D). You can install the demo repository and see it in action immediately, or follow along with these steps.

First you will need to follow the installation guide for the Laravel AI SDK and make sure to have at least one AI provider configured.

I used the Artisan command to create an agent and trimmed it down to the mere basics that I needed:

<?php

namespace App\Ai\Agents;

use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;

class Librarian implements Agent, HasTools
{
    use Promptable;

    public function __construct() {}

    public function instructions(): Stringable|string
    {
        return 'You are a cartography librarian, providing details on historic cartographers.';
    }

    /**
     * @return Tool[]
     */
    public function tools(): iterable
    {
        return [];
    }
}

A good agent would have far more elaborate instructions, but for this demo this would do the trick.

The demo application indexes a list of cartographers with their country of origin and the time period they lived in (e.g. the Enlightenment). I would like the agent to be possible to search through that index. So the next step is to create an agent tool:

<?php

namespace App\Ai\Tools;

use App\Models\Cartographer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Log;
use JeroenG\Explorer\Domain\Syntax\Compound\BoolQuery;
use JeroenG\Explorer\Domain\Syntax\Matching;
use JeroenG\Explorer\Domain\Syntax\Nested;
use JeroenG\Explorer\Domain\Syntax\Terms;
use JeroenG\Explorer\Infrastructure\Scout\ElasticEngine;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Laravel\Scout\Searchable;
use Stringable;

final class ExplorerSearch implements Tool
{
    /** @var class-string<Searchable> $modelClass */
    public function __construct(array $periodNames)
    {
    }

    public function description(): Stringable|string
    {
        return 'Use this tool when the user wants to search through documents';
    }

    public function handle(Request $request): Stringable|string
    {
        Log::info('tool called', $request->toArray());

        $search = Cartographer::search();

        if ($request->has('countries')) {
            $search->must(new Terms('place', explode(',', $request['countries'])));
        }

        if ($request->has('periods')) {
            $periodQuery = new BoolQuery();

            foreach (explode(',', $request['periods']) as $period) {
                $periodQuery->must(new Matching('period.name', $period));
            }

            $search->must(new Nested('period', $periodQuery));
        }

        return $search->take(10)->get();
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'countries' => $schema->string()->description('list of comma separated countries')->required(),
            'periods' => $schema->string()->description('list of comma separated period names')->required(),
        ];
    }
}

A tool always has a description that explains to the agent when to use the tool and a schema that explains how to use it. In this case it allows the agent to search through the list of cartographers and find any based on a country or period name. This schema is quite straightforward, you can define much more elaborate schemas with nested arrays and objects.

The handle() is very much based on the SearchController that is in the demo application, it starts of with a Laravel Scout search model and expands it with Elasticsearch-specific queries from Explorer.

Last thing to do now is to add this tool to the agent:

    public function tools(): iterable
    {
        return [
            new ExplorerSearch(),
        ];
    }

You may call the agent in any way you like, for this demo I decided to create a route in routes/web.php that returns whatever is the output:

Route::get('/agent', function () {
    return (new \App\Ai\Agents\Librarian)->prompt('Search through documents and return the list of cartographers from Netherlands or France from the Enlightenment');
});

Based on the prompt (which is slightly suggestive in this case) the agent decided which tool(s) to call. Thanks to the logger in the tool we are able to see the agent calling the tool with the correct request after visiting the /agent route:

local.INFO: tool called {"countries":"Netherlands,France","periods":"Enlightenment"}

It's a kind of magic!