Skip to content

Ai

The AI module (Amarant\AI) provides a provider-agnostic layer for talking to LLM APIs, an agent abstraction with tool-calling and conversation history, and a small set of supporting infrastructure (response caching, file attachments, a frontend conversations endpoint).


Capabilities

  • ProvidersAiProviderInterface implementations that translate a provider-agnostic completion request into a specific vendor's wire format. Ships with OpenAI and Anthropic providers; both also work against any OpenAI/Anthropic-compatible local server (see Local models).
  • AgentsAgentInterface implementations that own a model, a system prompt, and (optionally) tools and conversation history. Agents are registered by name in an AgentPoolInterface and resolved at runtime, e.g. from an API request.
  • ToolsToolInterface implementations an agent can call mid-conversation. Parameters are described with the framework's fluent JSON schema builder (Amarant\Framework\Data\Schema\Json\Schema).
  • Conversation historyConversationStoreInterface persists conversations/messages so an agent can resume a customer's chat across separate HTTP requests.
  • AttachmentsAttachmentInterface implementations (image, document) that let a message carry files.
  • Response caching — an HTTP middleware that caches provider responses by request hash, scoped by whatever request dimensions (e.g. requester identity) are registered.
  • Conversations API — a config-gated, rate-limited frontend endpoint (POST /ai/v1/conversations) that lets any registered agent be reached over HTTP.

Providers

A provider is the thing that actually calls an LLM API. Providers are routed to by model name, not by explicit selection — AiProviderPoolInterface::getForModel() asks each tagged provider whether it recognizes the requested model, and uses the first one that says yes.

interface AiProviderInterface
{
    public static function getName(): string;

    public function supports(CallContextInterface $context): bool;

    public function call(CallContextInterface $context): ResultInterface;
}

CallContextInterface carries the model name, the input (a CompletionRequest for a normal call, or a CompletionBatchContext for a concurrent batch), and free-form options.

Two providers ship with the module:

Provider getName() supports() matches
OpenAiProvider openai Model starts with gpt-, chatgpt-, o1, o3, o4or matches whatever model is configured in OpenAiConfiguration::getModel() (see Local models)
AnthropicProvider anthropic Model starts with claude-

Writing a custom provider

Implement AiProviderInterface and tag it with DiEnum::AI_PROVIDER so AiProviderPoolInterface can discover it:

Vendor/ModuleName/Configuration/Di.php
use Amarant\AI\Contract\AiProvider\AiProviderPoolInterface;
use Amarant\AI\Enum\DiEnum as AiDiEnum;

$container->tag([MyCustomProvider::class], [AiDiEnum::AI_PROVIDER]);

AiProviderPoolInterface::getForModel() iterates tagged providers in tag-registration order and returns the first one whose supports() returns true — order matters if two providers could both match the same model name. get(string $name) looks a provider up directly by its getName() instead of by model.

Note

CallContextInterface and AiProviderInterface deliberately have no concept of tools or history — a provider only ever sees a fully-built CompletionRequest. Tool support and history live one layer up, in the agent.


Completion primitives

These are the provider-agnostic data classes every provider translates to/from its own wire format:

Class Purpose
CompletionRequest model, messages (CompletionMessage[]), options, tools (Tool[])
CompletionMessage One turn: role (CompletionMessageRoleEnum), content, optional toolCalls, toolCallId, attachments
CompletionResult A provider's response: content, finishReason, usage, rawResponse, toolCalls
Tool Wire-level tool schema handed to a provider: name, description, parameters (accepts a plain array or a JsonSchemaInterface)
ToolCall A model's request to invoke a tool: id, name, arguments

CompletionMessageRoleEnum has four cases: SYSTEM, USER, ASSISTANT, TOOL. A TOOL-role message carries the result of executing a tool and must set toolCallId to the ToolCall::$id it answers.


Tools

A tool is a plain class implementing ToolInterface:

interface ToolInterface
{
    public static function getName(): string;

    public function getDescription(): string;

    /**
     * @return array<string, mixed>
     */
    public function getParameters(): array;

    public function execute(array $arguments): mixed;
}

getParameters() returns a JSON schema describing the tool's arguments — build it with Amarant\Framework\Data\Schema\Json\Schema rather than hand-writing an array:

use Amarant\Framework\Data\Schema\Json\Schema;

public function getParameters(): array
{
    return Schema::object()
        ->property('orderNumber', Schema::string()->description('The order number.'))
        ->toArray();
}

execute() receives the decoded arguments and returns anything — a string is sent back to the model as-is, anything else is JSON-encoded first (see InteractsWithProvider, below).

Registering a tool

Tag the class with DiEnum::AI_TOOLToolPoolInterface resolves tools by name via ToolInterface::getName() (a static method, so tools are matched without instantiating every candidate):

Vendor/ModuleName/Configuration/Frontend/Di.php
use Amarant\AI\Enum\DiEnum as AiDiEnum;

$container->tag([GetOrderStatusTool::class], [AiDiEnum::AI_TOOL]);

An agent doesn't have to go through the pool — ToolAwareAgentInterface::getTools() can also just return tool instances constructed/injected directly (useful for a bespoke agent implementation, see CustomToolAgent in the AI module's own tests).


Agents

AgentInterface is the minimal contract every agent must satisfy:

interface AgentInterface
{
    public function getModel(): string;

    /** @return string[] */
    public function getSystemPrompts(): array;

    public function getMaxSteps(): int;

    public function converse(array $messages, array $options = [], ?int $maxSteps = null): ResultInterface;
}

Everything beyond that is an optional capability interface, detected via instanceof by the shared tool-call loop trait — an agent only implements what it actually needs:

Interface Adds An agent that skips it is treated as...
ToolAwareAgentInterface getTools(): ToolInterface[] having no tools at all
HistoryAwareAgentInterface resolveConversationId(), getHistory(), recordHistory() stateless — every converse() call starts fresh
RestrictedAgentInterface isAllowed(RequestContextInterface): bool always allowed (enforcement is the caller's job — see below)

The generic Agent class

For a simple agent (a model + system prompt + a fixed set of tools resolved from the pool by name), use the ready-made Agent class rather than writing a new implementation:

Vendor/ModuleName/Configuration/Di.php
use Amarant\AI\Agent\Agent;
use Amarant\AI\Contract\Agent\AgentPoolInterface;
use Amarant\Framework\Enum\DiEnum;

$container
    ->bind(
        'agent.vendor.module.faq',                              // (1)
        fn ($container) => $container
            ->getBindBuilder()
            ->parent(Agent::class)                               // (2)
            ->argument('systemPrompts', ['You answer FAQ questions about the store.'])
            ->argument('toolNames', ['get_faq_answer'])
            ->build()
    )
    ->extend(
        AgentPoolInterface::class,
        function ($bind) {
            $bind->addValueToArgument('agents', DiEnum::getIdInjection('agent.vendor.module.faq'), 'faq_agent');
            return $bind;
        }
    );
  1. Agent::class is bound as a template in the AI module's own DI config — inherit from it the same way named cache pools inherit from RedisPool.
  2. model is already wired to the system/ai/model config value on the template; override it with another argument('model', ...) call if this agent should use a different model.

AgentPoolInterface itself follows the same named-instance pattern as PoolList for cache pools: extend(AgentPoolInterface::class, ...) + addValueToArgument('agents', ..., $name), resolved later via AgentPoolInterface::get($name).

Writing a custom agent from scratch

When an agent needs its own logic (e.g. restricting access, scoping history by identity), implement AgentInterface directly and pull in the InteractsWithProvider trait for the tool-call loop — it only requires getModel(), getSystemPrompts(), getMaxSteps(), and a getProviderPool() hook, and works for any AgentInterface implementer, not just Agent.

This is the actual order-status support agent from the Amarant\SalesAIAgent module — restricted to logged-in customers, with conversation history scoped by customer identity:

Amarant/SalesAIAgent/Agent/OrderStatusAgent.php
final class OrderStatusAgent implements
    AgentInterface,
    ToolAwareAgentInterface,
    RestrictedAgentInterface,
    HistoryAwareAgentInterface
{
    use InteractsWithProvider;
    use RemembersHistory;

    public const int DEFAULT_MAX_STEPS = 5;
    public const string AGENT_NAME = 'order_status_agent';

    public function __construct(
        private readonly string $model,
        private readonly ToolPoolInterface $toolPool,
        private readonly AiProviderPoolInterface $providerPool,
        private readonly ConversationStoreInterface $conversationStore,
        private readonly int $maxSteps = self::DEFAULT_MAX_STEPS
    ) {
    }

    public function getModel(): string
    {
        return $this->model;
    }

    public function getSystemPrompts(): array
    {
        return [self::SYSTEM_PROMPT];
    }

    public function getTools(): array
    {
        return [$this->toolPool->get(GetOrderStatusTool::getName())];
    }

    public function getMaxSteps(): int
    {
        return $this->maxSteps;
    }

    public function isAllowed(RequestContextInterface $requestContext): bool
    {
        $identity = $requestContext->getIdentity();
        return $identity instanceof CustomerIdentity && $identity->isFullyAuthenticated();
    }

    protected function getProviderPool(): AiProviderPoolInterface
    {
        return $this->providerPool;
    }

    protected function getConversationStore(): ConversationStoreInterface
    {
        return $this->conversationStore;
    }

    protected function getAgentId(): string
    {
        return self::AGENT_NAME;
    }

    // RemembersHistory defaults every conversation to an unscoped identity —
    // override this to bind conversations (and thus history) to whoever is
    // actually logged in, so one customer's chat can never surface in another's.
    protected function resolveIdentity(RequestContextInterface $requestContext): array
    {
        $identity = $requestContext->getIdentity();
        if (!$identity instanceof CustomerIdentity) {
            return [null, null];
        }
        return [CustomerIdentity::TYPE, (string)$identity->getId()];
    }
}

Registered in the module's area-scoped DI config (agents that are only ever reached from the frontend belong in Configuration/Frontend/Di.php, not the global Configuration/Di.php):

Amarant/SalesAIAgent/Configuration/Frontend/Di.php
$container
    ->tag([GetOrderStatusTool::class], [AiDiEnum::AI_TOOL])
    ->bind(
        OrderStatusAgent::class,
        fn ($container) => $container
            ->getBindBuilder()
            ->type(OrderStatusAgent::class)
            ->concrete(OrderStatusAgent::class)
            ->argument('model', DiEnum::getConfigInjection(ConfigEnum::AI_MODEL))
            ->build()
    )
    ->extend(
        AgentPoolInterface::class,
        function ($bind) {
            $bind->addValueToArgument(
                'agents',
                DiEnum::getIdInjection(OrderStatusAgent::class),
                OrderStatusAgent::AGENT_NAME
            );
            return $bind;
        }
    );

GetOrderStatusTool itself is a normal ToolInterface implementation — it resolves the order through OrderRepositoryInterface, checks ownership with the same SecurityManagerInterface::isGranted() guard pair the frontend "my orders" view uses, and reports a generic "not found" for both a missing order and one the customer doesn't own (so a wrong guess never reveals which case it was).

The tool-call loop (InteractsWithProvider)

converse() builds the message list (system prompts + prior history, if any + the caller's new message(s)), then loops:

  1. Send the conversation to the provider.
  2. If the response has no tool calls, record history (if history-aware) and return.
  3. Otherwise, execute each requested tool and append the results as TOOL-role messages, then go back to step 1.

Two safety mechanisms bound this loop:

  • Step budget (getMaxSteps(), or the $maxSteps argument to converse() for a one-off override) — each round that requires at least one new tool execution spends one unit of this budget. Throws AiAgentException when exhausted.
  • Repeat-call cache — if the model re-requests a tool call with the exact same name and arguments already executed earlier in this same converse() call, the cached result is reused instead of re-executing the tool, and the round doesn't spend step budget. This avoids double-executing non-idempotent tools and stops a model that keeps "re-confirming" an answer it already has from needlessly burning the budget. A separate, much larger cap (20 rounds) still exists purely as a backstop in case a model only ever repeats cached calls and never produces a final answer.

Note

Only the caller's message(s) and the eventual final assistant answer are persisted to history — the intermediate tool-call/tool-result round trip is only ever needed within that single converse() call. Replaying that scaffolding back as history on a later turn adds no information (the final answer already captures the outcome) and has been observed to nudge smaller local models into repeating tool calls instead of answering a follow-up question.

Conversation history (RemembersHistory)

RemembersHistory implements HistoryAwareAgentInterface against a ConversationStoreInterface. A class using it only needs to supply two hooks:

abstract protected function getConversationStore(): ConversationStoreInterface;
abstract protected function getAgentId(): string; // scopes lookups to this agent in the pool

By default every conversation is unscoped (resolveIdentity() returns [null, null]) — override it to bind conversations to whatever identity the agent can derive from the request context, as OrderStatusAgent does above. getHistoryLimit() defaults to 10 messages; override it to change how much prior context is replayed per call.

resolveConversationId() must be called before converse() — it either uses a caller-supplied conversation id, finds the most recent conversation for this agent+identity, or creates a new one:

$conversationId = $agent->resolveConversationId($requestContext, $input->conversationId);
$result = $agent->converse([new CompletionMessage(CompletionMessageRoleEnum::USER, $input->message)]);

Note

An agent that also restricts access (RestrictedAgentInterface) owns the identity-scoping decision itself — the caller only ever supplies the request context, never an identity type/id directly. This keeps "is this conversation allowed" and "whose conversation is this" as a single agent-owned decision.

Restricting access (RestrictedAgentInterface)

RestrictedAgentInterface::isAllowed(RequestContextInterface): bool gates whether an agent may be used at all. Enforcement is the caller's responsibility — the shared tool-call loop has no access to a RequestContextInterface and does not check this itself:

if ($agent instanceof RestrictedAgentInterface && !$agent->isAllowed($requestContext)) {
    throw ApiException::becauseResourceNotAllowed(/* ... */);
}

This is exactly what the built-in SendMessage controller (the Conversations API, below) does before calling converse().


Attachments

A message can carry files via CompletionMessage::$attachments (AttachmentInterface[]). Two implementations ship with the module — ImageAttachment and DocumentAttachment (PDF only; there is deliberately no video attachment, since no supported provider accepts video input today).

Build one from any Symfony\Component\HttpFoundation\File\File (this covers both an UploadedFile from an HTTP request and a plain in-code File for internal callers that already have file content, not just end-user uploads) with AttachmentFactory::createFromFile():

use Amarant\AI\Data\Attachment\AttachmentFactory;

$attachment = AttachmentFactory::createFromFile($uploadedFile); // picks Image/Document by mime type

Attachment support is provider-specific — OpenAiProvider only accepts inline images through the chat completions API (a PDF attachment throws AttachmentException, since documents go through OpenAI's separate Files/Assistants API); AnthropicProvider supports both.


Conversations API

POST /ai/v1/conversations (Amarant\AI\Controller\Api\Frontend\V1\Conversation\SendMessage) lets any registered agent be reached over HTTP from the frontend. It is:

  • Config-gated — disabled (404) unless system/ai/conversations/api/enabled is on.
  • Rate limited — 20 requests/minute, fixed window, globally shared across all callers (registered in etc/frontend/api.php). It's intentionally left at the global scope rather than per-identity, since the endpoint allows any valid, allowed agent, not just one restricted to logged-in customers.
  • Multipart — request body is multipart/form-data: a data field (JSON-encoded ConversationDataInputDtoagentId, optional conversationId, message) plus a files array of up to 3 uploaded files.

Request flow inside the controller:

  1. Reject (403) if attachments are present but disabled, or a file exceeds the configured max size — checked before resolving the agent, so these settings are independently testable regardless of which agent is requested.
  2. Resolve the agent from AgentPoolInterface by agentId (throws AgentNotFoundException for an unknown name).
  3. If the agent is RestrictedAgentInterface and isAllowed() is false, reject (403).
  4. If the agent is HistoryAwareAgentInterface, resolve the conversation id.
  5. Call converse() and return the reply plus the conversation id.

Configuration

Config key (ConfigEnum) Default Description
system/ai/conversations/api/enabled 0 Whether the endpoint is reachable at all.
system/ai/conversations/api/allow_attachments 0 Whether the files field is accepted.
system/ai/conversations/api/attachment_max_size_mb 5 Per-file size limit when attachments are allowed.

Read through AiProviderConfiguration (isConversationsApiEnabled(), isConversationsApiAttachmentsAllowed(), getConversationsApiAttachmentMaxSizeMb()) — don't read ConfigEnum values directly from a ReaderInterface in new code.


Response caching

AiProviderResponseCacheMiddleware is a Guzzle handler middleware that caches a provider's raw HTTP response by a hash of the request body plus whatever a RequestScopeResolverInterface contributes — so a response computed for one requester can never be served back to a different one.

interface RequestScopeProcessorInterface
{
    public function process(RequestScope $scope): void;
}

Register a processor to add a new dimension to the cache key (e.g. the current customer's identity). Each processor obtains whatever it needs through its own constructor dependencies — it is never handed ambient request state, so it stays testable and explicit about what it depends on:

Vendor/ModuleName/Configuration/Di.php
use Amarant\AI\Enum\DiEnum as AiDiEnum;

$container->tag([CustomerIdentityScopeProcessor::class], [AiDiEnum::AI_CACHE_CONTEXT_PROCESSOR]);

The resolved scope is ksort()-ed before hashing so the cache key is deterministic regardless of processor registration order. Cached rows are cleaned up by the scheduled CleanupAiProviderResponseCacheJob (system/ai/response_cache_ttl_days, default 30).


Local models

OpenAiProvider::supports() also matches whatever model is configured in OpenAiConfiguration::getModel() — not just names starting with gpt-/o1/o3/o4 — so it works against any OpenAI-compatible local server. To run a model locally with Docker Model Runner:

# pull and serve a model behind an OpenAI-compatible API
docker model pull ai/gemma3
docker model run ai/gemma3

Then point the module at it in etc/defaults.php:

'openai' => [
    'base_url' => 'http://localhost:12434/engines/v1',
    'model' => 'ai/gemma3',
],

Warning

Smaller local models are noticeably less reliable at multi-step tool calling than a frontier hosted model — expect to tune system prompts and step budgets more aggressively, and lean on the repeat-call cache described above.


Reference

DI tags (Amarant\AI\Enum\DiEnum)

Tag Tagged with Discovered by
AI_PROVIDER AiProviderInterface implementations AiProviderPoolInterface
AI_TOOL ToolInterface implementations ToolPoolInterface
AI_CACHE_CONTEXT_PROCESSOR RequestScopeProcessorInterface implementations RequestScopeResolverInterface

Configuration (Amarant\AI\Enum\ConfigEnum)

Key Description
system/ai/model Default model used by agents bound from the Agent::class template.
system/ai/model/allow_fallback Whether a failed call may fall back to another provider.
system/ai/response_cache_ttl_days Retention for cached provider responses.
system/ai/conversations/api/* See Conversations API.
system/ai/openai/* OpenAI provider connection/model settings, including translation-specific overrides.
system/ai/anthropic/* Anthropic provider connection/model settings.
system/ai/deepl/auth_key DeepL translation source (unrelated to AiProviderInterface — a separate i18n translation source).

Exceptions

Exception Thrown when
AiProviderException A provider call fails (network, non-2xx, invalid response body).
AiProviderNotFoundException No tagged provider's supports() matched the requested model.
AiAgentException The tool-call step budget is exhausted without a final answer.
ToolNotFoundException A requested tool name isn't registered in the resolved tool set.
AgentNotFoundException AgentPoolInterface::get() is called with an unregistered agent name.
AttachmentException An unsupported mime type, or a provider that doesn't support the attachment type given.