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
- Providers —
AiProviderInterfaceimplementations 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). - Agents —
AgentInterfaceimplementations that own a model, a system prompt, and (optionally) tools and conversation history. Agents are registered by name in anAgentPoolInterfaceand resolved at runtime, e.g. from an API request. - Tools —
ToolInterfaceimplementations an agent can call mid-conversation. Parameters are described with the framework's fluent JSON schema builder (Amarant\Framework\Data\Schema\Json\Schema). - Conversation history —
ConversationStoreInterfacepersists conversations/messages so an agent can resume a customer's chat across separate HTTP requests. - Attachments —
AttachmentInterfaceimplementations (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, o4 — or 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:
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_TOOL — ToolPoolInterface resolves tools by name via
ToolInterface::getName() (a static method, so tools are matched without instantiating every candidate):
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:
Agent::classis bound as a template in the AI module's own DI config — inherit from it the same way named cache pools inherit fromRedisPool.modelis already wired to thesystem/ai/modelconfig value on the template; override it with anotherargument('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:
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):
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:
- Send the conversation to the provider.
- If the response has no tool calls, record history (if history-aware) and return.
- 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$maxStepsargument toconverse()for a one-off override) — each round that requires at least one new tool execution spends one unit of this budget. ThrowsAiAgentExceptionwhen 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) unlesssystem/ai/conversations/api/enabledis 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: adatafield (JSON-encodedConversationDataInputDto—agentId, optionalconversationId,message) plus afilesarray of up to 3 uploaded files.
Request flow inside the controller:
- 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. - Resolve the agent from
AgentPoolInterfacebyagentId(throwsAgentNotFoundExceptionfor an unknown name). - If the agent is
RestrictedAgentInterfaceandisAllowed()is false, reject (403). - If the agent is
HistoryAwareAgentInterface, resolve the conversation id. - 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.
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:
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:
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. |