Packstub

The agent

The assistant is a laravel/ai agent, Packstub\Agents\Ai\Agent, with your persona and domain on top of generic rules, the tool list of your server, a middleware pipeline with the budget check, and a turn job that records what every answer cost. This page covers the class and how a turn runs; a chat surface is what Filament Agents adds in a panel.

Conversations

Conversations and messages are laravel/ai's Conversation and ConversationMessage models, stored in the agent_conversations and agent_conversation_messages tables by Packstub\Agents\Support\AgentConversationStore, the package's conversation store (bound as laravel/ai's ConversationStore). One person never reads another person's conversations. A question is recorded before the provider is called: if the provider fails or times out, the question stays in the conversation and can be retried. Every answer can be rated (agent_message_feedback), which your app can read to find the questions that go wrong.

How a turn runs

A question (or an approval decision) becomes a row in agent_turns, and the RunAgentTurn job produces the answer:

use Packstub\Agents\Support\AgentConversationStore;
use Packstub\Agents\Support\AgentTurns;

$conversation = app(AgentConversationStore::class)->startConversation($user, $question);
$turn = app(AgentTurns::class)->enqueue($conversation, $user, ['prompt' => $question], null, 'auto', null);

The job captures who asked and where (AgentContext::capture(): the user, the guard, the workspace key, the locale) and restores it on the worker (enter()), so tools, ability checks and the prompt behave as they did in the request. It runs the middleware pipeline around each model round-trip (the budget check first), streams the answer from the provider and writes what it has so far to the row (the status line says when the model is reasoning, writing or calling a tool), then stores the answer as laravel/ai does: one message per turn, its steps — text, reasoning, tool calls with their results — in the steps column. When the turn ends its row keeps the record — provider and model, tokens, tools called, duration, how it ended — see What each turn cost.

GET {chat.path}/chat/{conversation}/turn (chat.poll_interval apart, AgentTurns::pollInterval() in code) returns {"active": {"id", "status", "statusText", "html"} | null, "version"}: the running turn with its status line (AgentTurns::statusText(): what the job last reported, "Thinking…" before it did, the missing-worker hint) and the answer so far rendered, and a version stamp that changes whenever the conversation did. AgentTurns::active(), queued() and latest() read the state back in code, participant($turn) gives the person a turn belongs to, remove($turn) takes a queued follow-up out of the line before it starts, and Markdown::render($text) turns a stored answer into the same HTML the endpoint returns. Follow-ups wait as queued rows and start, in order, as soon as the previous turn is done. AgentTurns::requestStop() cuts a running answer short: what the assistant had written stays as its answer, marked "(stopped)". An answer the provider ended early — a stream that closed mid-answer, the model's length limit, a content filter — is kept the same way, marked "(cut short)" with the reason (AgentTurns::cutShortReason()).

Run a queue worker for the jobs (see Installation). A turn no worker takes within chat.worker_wait seconds gets a status line that names the missing worker (AgentTurns::statusText() is that line for a chat surface of your own, awaitingWorker($turn) the bare check). A job the queue never finishes — a worker that died mid-answer — is marked failed after chat.job_timeout, with the question kept. With chat.driver set to sync (AGENT_TURN_DRIVER=sync) the job runs inside the request, whatever queue the app uses.

A chat surface of your own

Packstub\Agents\Support\AgentChat is one person's chat without a UI: what a surface reads and what it does, so a JSON API, a Livewire component, an Inertia page or a console command needs no logic of its own. Filament Agents' chat page is one surface over it.

use Packstub\Agents\Support\AgentChat;

$chat = AgentChat::for($user);                       // a new chat, on the person's remembered model
$chat = AgentChat::for($user, $conversationId, 'fast', 'orders/12'); // an existing one (not found unless it is the person's), a model key, a page context

$turn = $chat->send('Which orders are waiting?');    // the AgentTurn queued (null: empty question, agent off)
$chat->conversation();                               // set by the first question
$chat->messages();                                   // the transcript, see below
$chat->live();                                       // ['active' => the running turn, 'queued' => the questions behind it, 'held' => decisions waiting for the other proposals, 'ended' => how the last one failed, 'deferred' => the turn that starts when the chat is opened]
$chat->decide('call_1', approve: true);              // a proposal's decision, as a turn
$chat->retry(); $chat->regenerate(); $chat->resend('…'); $chat->stop();
$chat->retry($messageId);                            // an earlier question that never got its answer (below)
$chat->removeQueued($turnId); $chat->editQueued($turnId); // take a waiting question out of the line (editQueued hands its text back)
$chat->rate($messageId, 'up');                      // a message outside this chat is not found
$chat->history();                                    // the context meter: share of the window, breakdown, what the chat cost
$chat->compress(); $chat->continueInNew();           // fold the older part into the summary (false: nothing older; ChatBusy while a turn runs); start a new chat from a summary of this one
$chat->suggestions(); $chat->title(); $chat->owns($id); $chat->ownConversations();
$chat->send('What is this?', [$image]);            // with attachments (below)
$chat->continueAnswer();                             // carry on an answer the length limit cut short
$chat->versions($questionId); $chat->showVersion($questionId, $versionId); // earlier answers to a question, and put one back
$chat->rename('Live widgets'); $chat->pin(); $chat->unpin(); $chat->pinned(); AgentChat::pinnedIds($user);
$chat->rate($messageId, 'down', 'Beta is retired'); // with a note
$chat->transcript();                                 // the chat as Markdown, for an export
AgentChat::search($user, 'alpha');                   // the person's chats whose messages or title match, with a snippet

messages() returns the conversation oldest first, each with role, text, html (an answer rendered), tools — every call with its question (the proposal as a sentence), pending, held (a decision waiting for the other proposal of the same answer), rejected, result, readOnly — charts (Chart.js payloads from chart results), tables (a show-table result, when the app registers agent resources), attachments (the files sent with a question: name, mime, image, url when the disk gives one), rating and ratingNote, versions (how many earlier answers a question has), continuation (a question sent by Continue, which a surface hides) and continued (an answer that carries on the one above), stopped, posted (a message the app posted), cutShort, answeredBy, sources (the pages an answer cites, from a provider's web search), and what a surface may offer on it: unanswered (a Retry, with ended: how the question's last turn went), editable (Edit on the last question), regenerable (Regenerate on the last answer), continuable (Continue, when the model's length limit cut the last answer), only while nothing runs or waits — a decision held for the other proposal of its answer keeps the chat busy too, so the paused answer cannot be edited or produced again under it. Poll or stream the turn endpoint for the running answer, then read messages() again. On the sync driver the turn a method returns has already run, so its status and error say how it went. The static helpers phrase what a surface shows: question($tool, $name, $arguments), resultText($result), chartFromResult(), tableFromResult(), cutShortText($reason), duration($ms), breakdownLabels(), modelMenu() (the picker's entries by provider, with the model name under a label) and writeToolNames().

Retry, on any unanswered question

A question is recorded before the provider is called, so one that failed, was refused by a middleware or was stopped before anything arrived stays in the transcript. messages() marks each of them unanswered, wherever it sits: the last question of the chat, or an earlier one the person asked something else after. ended says how its last turn went (status, reason, error), for the line a surface shows under it.

retry() sends the last question again. retry($messageId) does the same for an earlier one: the question moves to the end of the chat first, so its answer lands under it and the assistant reads what was said since, and its earlier answers and turns follow it to the new id. Either way the question goes out whole, with the files attached to it and the records it mentions; Regenerate and Edit send those again as well. A question that has its answer, another chat's question and a busy chat all return null.

The record a chat is about

A page context (orders/12) given to AgentChat::for() is recorded with every question asked under it, so the chat stays about that record: AgentChat::for($user, $conversationId) without a context takes the one the last question was asked with, and the model keeps reading the record's summary on every follow-up. A context passed when the chat is reopened wins, and is the conversation's from then on. contextLabel() is what a surface shows ("About Order RO-00012"), contextUrl() the record's page to link it to (PageContext::url($ref): the resource's agentRecordUrl(), or the url of its summary).

A message the app posts as the assistant

A digest, a reminder, a notice — text your app computed — can be posted into a conversation as the assistant's own message, so the person reads it where they would read an answer and can reply to it:

$store = app(AgentConversationStore::class);

$conversation = $store->startConversation($user, $text, title: 'Monday digest'); // or an existing one
$store->storePostedMessage($conversation, $user, $text);

startConversation() takes a title, so a conversation can be opened without a question (without one, the title is the first question, as before). The posted message is an ordinary assistant row marked posted in its meta (AgentConversationStore::wasPosted($meta)): messages() returns it like any answer, with posted set beside stopped, and the next turn reads it as history — a window that opens on a posted message gets one line in front that says the assistant posted it, since a provider may require the person to speak first. No provider wrote it, so no tokens are spent: AgentBudget leaves posted rows out of the day's turns, and the turn log lists it as a done turn ended posted, without provider, usage or cost. A posted last message cannot be regenerated, and a question that a posted message follows cannot be edited (editing would drop it). A conversation your app titled keeps its title: the provider titles a chat only after its first answer, when no assistant message exists yet.

A first turn that runs when the chat is opened

When your app opens a conversation for someone — a close to walk through, a summary to discuss — store what it asks now and let the answer be produced when they come to read it:

$conversation = $store->startConversation($user, $question, title: 'September close');
$turn = app(AgentTurns::class)->defer($conversation, $user, ['prompt' => $question], 'auto', 'orders/12');

defer() records the question in the transcript at once and stores the turn as deferred: outside the line (startNext(), active(), state() and reconcile() ignore it), never pruned while it waits, and deleted with the conversation (deleteConversation() takes every turn that has not ended). A conversation your app titled keeps its title; one opened without a title is titled by the provider once the first answer is in, as a chat started from the composer is (pass 'title' => true in $input to have it retitled either way). AgentChat::live() reports it as deferred (its id and, after a refused open, the reason), idle() is false until it ran, and the question is not offered a Retry — it is being answered when opened. The surface that opens the conversation calls AgentTurns::startDeferred($conversation, $participant): under a lock on the conversation (Cache::lock, so the cache store must support locks — redis, memcached, dynamodb, file, array, or database with its cache_locks table) the owner's deferred turn is queued and started once, with who is acting and where captured at that moment (the panel, the guard and the locale of the opener), AgentModels::enabled() and AgentBudget::refusal() checked then rather than when the row was written. It returns the turn that started, or null when there is none, another request is starting it, the agent is switched off, the conversation was opened from another workspace than the one the question was asked in (a conversation is not scoped to a workspace, the turn is: it runs where its context and tools are), or the budget refused it — then the turn stays deferred for a later open, with the reason in its error. Anyone who is not the owner starts nothing. Filament Agents' chat page calls it whenever it opens an existing conversation; AgentTurns::deferred($conversation) reads the waiting turn in code.

Live updates

While a turn runs a surface reads the same state two ways. GET {chat.path}/chat/{conversation}/turn returns it once (AgentTurns::state($conversation) in code): {"active": {"id", "status", "statusText", "html", "tools"} | null, "version"} — the running turn with its status line, the answer so far rendered, the tools called so far, and a version stamp that changes whenever the conversation did. GET {chat.path}/chat/{conversation}/stream pushes the same object as a server-sent turn event every time it changes (checked every chat.stream_interval milliseconds), a comment line as a heartbeat otherwise, then an end event once nothing runs; it closes after chat.stream_seconds and the browser's EventSource reconnects on its own. Pass ?version= with the stamp the client last saw to skip an event that says nothing new. Filament Agents streams, and polls every chat.poll_interval milliseconds when the stream cannot be held open.

Attachments

A person can attach files to a question — a screenshot, an invoice, a CSV — and the provider reads them with it. AgentAttachments::store($uploadedFile) puts an upload on the attachments disk (config chat.attachments: the disk, the directory, the size cap, the accepted MIME types) and returns the laravel/ai file to pass to send(): an image for image/*, a document otherwise — check which your provider reads. The files are stored with the question (the provider reads them again when the history is replayed), described by messages() for a thumbnail or a link (AgentAttachments::describe(); temporary_urls asks the disk for signed URLs), and deleted with the conversation. AgentAttachments::accepts($mime), maxKilobytes() and maxPerQuestion() are the limits a composer shows. A question with a file and no words goes as "(see the attached file)".

Continue, versions, rename, pin, search, export

  • Continue. When the model's length limit cut an answer (cutShort is length), continueAnswer() sends a continuation turn: its question is stored with a continuation mark so a surface hides it, and the answer that follows is continued — shown as the rest of the one above.
  • Versions. Regenerate and Edit no longer lose the earlier answer: the rows that followed the question are kept in agent_answer_versions (AgentAnswerVersion) and messages() counts them on the question as versions. versions($questionId) lists them (text, HTML, when), showVersion($questionId, $versionId) puts one back — the current answer becomes a version in turn, the question's text comes back with it (an edit rewrites the question), and the chat carries on from the restored answer. Only the last question's answers can be put back, and only while nothing runs.
  • Rename and pin. rename($title) gives a chat the person's own title; pin() / unpin() keep it at the top of their list (agent_pinned_conversations; AgentChat::pinnedIds($user) for the list order).
  • Search. AgentChat::search($user, $words) finds the person's chats whose messages or title contain the words, newest first, with a snippet of the first matching message.
  • Export. transcript() is the chat as Markdown: the title, every question and answer with its time, proposals as one line each, attachments named.

Long chats

A chat can go on as long as you like; what changes is what the model reads. Each turn replays the most recent messages that fit the history window (history.max_tokens, estimated), cut on turn boundaries so a tool call keeps its result. Tool results older than a few turns (history.keep_tool_results_turns) are replaced by a one-line placeholder — the stored transcript is untouched. Messages that fall out of the window are folded into a rolling summary written by the provider's cheapest model (the SummaryAgent side agent) and stored per conversation (agent_conversation_summaries); the model reads it ahead of the verbatim tail, and the summary grows in place rather than being rewritten, so a provider's prompt cache keeps hitting (see Prompt caching).

AgentConversationStore::contextUsage($conversation) reports the share of the window in use and a breakdown (the rolling summary, questions, answers, tool calls, tool results kept or pruned, all estimated at four characters per token) next to what the chat cost so far over its recorded turns. compactNow($conversation, $summarizer, $keepTurns) folds everything but the last $keepTurns exchanges into the rolling summary, so the next question starts from a short window — a chat surface passes AgentConversationStore::providerSummarizer($provider) and compressKeepTurns() (history.compress_keep_turns); continueConversation($conversation, $participant, $title, $summarizer) starts a new chat that opens with the old one summarized; history.notice_share and history.meter_share are the thresholds a chat surface uses to suggest a new chat or show a meter. There is no hard stop — compaction keeps every chat answerable — but a fresh chat per topic gives the sharpest answers and the smallest bills.

Approvals

When the agent calls a write tool, laravel/ai pauses the turn with the tool's name and arguments as a pending approval: the answer is stored with status paused, the call keeping the tool's question as its approval_reason. The turn resumes with the decision (AgentTurns::enqueue() with the approval decisions in its input instead of a prompt) and the tool either runs or reports that it was rejected (AgentTurns::rejectionResult()); what the model says next is folded into the paused answer, as laravel/ai 1.0 stores a resume — unless the person decided in words ("Yes, go ahead."), which is recorded as a reply, so the answer to it is stored after it. A turn the provider gives up on midway is stored too, with status failed, the steps it completed and the error in meta.error; messages() marks it (failed, error) and offers Regenerate. The generic rules ask the model not to claim something was done until the tool result confirms it and never to chain destructive changes with anything else in one turn. A chat surface shows the arguments, not the model's summary of them, so a person can see a wrong target before it runs.

Events

PromptFlagged (the prompt guard read a question as something other than safe) and OutputRedacted (the redactor replaced something) report on the guard rails. The engine also fires four events (Packstub\Agents\Events) for the turn itself, each carrying the AgentTurn: TurnStarted once the job took the turn and is about to call the provider; ToolCalled for every tool the model calls, with the call id, the tool name and the arguments as the model sent them (a read tool runs at once, a write tool becomes a proposal); ProposalDecided when a decision turn is about to apply an approval or a rejection, with the call as it was proposed; TurnEnded when the turn ended — done, stopped or failed — with the row carrying provider, model, usage, cost, tools, duration and finish reason. A metrics sink or a Slack notification on a failed turn or a rejected proposal hang off these (every call is already on record in the tool call log); laravel/ai's own ToolInvoked and ToolApprovalResolved fire as well.

When the agent is off

AgentModels::enabled() is false — and a chat surface hides itself — when there is no provider key for the configured provider, for the provider of any catalog entry or from the workspace, when AGENT_ENABLED=false, or when the workspace is switched off in agent_limits. The MCP endpoint is independent of that.

The assistant without a chat

Packstub\Agents\Support\AgentRun asks a question as a person and returns the answer in the same call — from a scheduled command, a job, a webhook, a test — whatever chat.driver says:

use Packstub\Agents\Support\AgentRun;

$answer = AgentRun::as($user)->in($team)->model('fast')->ask('Which orders are waiting for a phone call?');

$answer->ok(); $answer->text; $answer->html(); $answer->tools(); $answer->turn; $answer->conversation;
$answer->proposals; // write calls the model proposed: they wait in the chat for the person's decision

The turn goes through everything a chat turn does — the budget, your middleware, the tools the person's role allows, the record on agent_turns — and lands in a conversation the person can open later; continuing($conversationId) adds to one of theirs, context('orders/12') asks about a record, with([$file]) attaches files. A write tool the model proposes is not run: nobody is watching, so the proposal waits in the chat. The morning report is one line in the scheduler:

Schedule::call(fn () => Mail::to($owner)->send(new DailyBriefing(AgentRun::as($owner)->ask('What needs attention today?'))))->dailyAt('07:30');

php artisan packstub-agents:run "What needs attention today?" --user=ada@example.com does the same from the console (--tenant=, --model=, --context=, --conversation=; --json prints the record: status, text, tools, proposals, usage, cost).

The assistant by email

With AGENT_EMAIL=true and a shared secret in AGENT_EMAIL_SECRET, POST {chat.path}/email takes the inbound-mail webhook of your mail provider (Postmark, Mailgun, SES, your own script) with the secret in the X-Agent-Secret header. The sender must be a person of the app — found by email on the guard's user provider, or by Agents::participantByEmailUsing(fn (string $email) => …) — and the question is asked as them through AgentRun; the answer goes back as a reply (AgentAnswerMail, the answer rendered, proposals listed as waiting for a decision in the chat, a "reply to continue" line). The reply's subject carries a tag ([chat 0199a1b2]) and its Message-ID names the conversation, so a reply — by subject or by threading — continues the same chat; a mail from an address that is nobody's, or one that names a workspace (tenant) the sender is not a member of or that does not exist, is dropped without an answer. The controller reads the plain fields (from, subject, text, message_id, in_reply_to, references, tenant for a workspace slug) and the usual provider names (From, Subject, TextBody / stripped-text / body-plain, MessageID); quoted history and signatures are cut before the question is sent. EmailChannel::receive(InboundEmail::fromArray($payload)) is the same from your own code.

The Agent class

php artisan packstub-agents:agent scaffolds app/Ai/Agents/Assistant.php (give it a name for another class; --force overwrites):

namespace App\Ai\Agents;

use Packstub\Agents\Ai\Agent;

class Assistant extends Agent
{
    protected function persona(): string
    {
        return 'You are Ask Acme, the back-office assistant of an online shop. You work with its data through tools.';
    }

    protected function domain(): string
    {
        return <<<'PROMPT'
        - Orders move from placed to paid to shipped; a cancelled order keeps its number.
        - Stock is counted per warehouse; a product can be in several.
        - Warehouse staff may confirm and ship; only managers may refund.
        PROMPT;
    }

    /** @return list<string> */
    protected function workRules(): array
    {
        return [
            ...parent::workRules(),
            'Order references can be the number (RO-00012), the shop number (#1042) or an id.',
        ];
    }

    /** @return list<string> */
    protected function context(): array
    {
        return [
            ...parent::context(),
            'Warehouses: '.Warehouse::query()->pluck('code')->join(', ').'.',
        ];
    }

    /** @return list<string> */
    public function suggestions(): array
    {
        return [
            'What needs attention today?',
            'Which orders are waiting for a phone call?',
            'Revenue this week by store, compared to last week',
        ];
    }
}

Register it with Agents::useAgent(Assistant::class) in a service provider. Until you do, the package's DefaultAgent answers with only the registered tools and a generic persona.

suggestions() is what an empty chat offers as one-click starter questions, in the person's language. The default set is generic: what needs attention today, "Show me the latest orders." for the first two agent resources, and what the assistant can do; a chat opened from a record ($this->pageContext) gets "What should I know about Order RO-00012?" and "What is the next step for Order RO-00012?" instead. Return your own from the domain, and keep the parent's page-context ones with [...parent::suggestions(), …] when a record is open.

How the prompt is assembled

The prompt comes in two blocks:

  1. Static, the system prompt: the persona, "What the workspace is" (your domain()), "How to work" (workRules()) and "How to answer" (answerRules()). It is byte-identical from one turn to the next.
  2. Dynamic, small and per turn: date and time, the workspace name, the person and their role, the answer language (from the app locale), and the page context when the chat was opened from a record. It is prepended to the question by the AttachContext middleware, the last in the pipeline, so it sits behind the history rather than in front of it; it is written into the question message once per turn, so the messages a tool step sends are the ones the provider already has, and a provider that continues a turn server-side (OpenAI and xAI, previous_response_id) sends only the new tool results. A turn that resumes an approval has no question and goes without it — the model continues the step the block already informed.

Prompt caching

Providers charge a fraction for the part of a prompt they have already read, as long as it is the same bytes in the same order: the tool list, then the system prompt, then the messages. The package keeps that prefix stable and marks it where the provider needs a mark:

  • The system prompt is the static block alone. On Anthropic it closes with a cache_control: ephemeral breakpoint, so the tool definitions and the instructions cost once per five minutes of activity, whatever happens later in the chat.
  • The history is replayed as stored. The turns whose tool results were already reduced to a placeholder (older than history.keep_tool_results_turns) do not change again, so on Anthropic the newest answer among them carries a second breakpoint, moving forward one turn at a time: every later turn reads that part from the cache and pays in full only for the recent turns still being pruned and the new question. A chat too short to have a settled turn puts the breakpoint on the rolling summary when there is one. OpenAI, Gemini and xAI cache every prefix they have seen on their own; the static system prompt is what lets the history count as one.
  • The rolling summary is extended, not rewritten, so its prefix survives a compaction; the summary message itself changes then, and that one turn reads the history fresh.

The turn log records cache_read_input_tokens and cache_write_input_tokens per turn (see Observability); on a second turn of a chat the reads should cover the system prompt and, a few turns in, most of the history. A context() line that changes on its own — a live count, the time — costs nothing extra, since the whole dynamic block sits behind the cached prefix; what breaks the cache is a change to the tool list (a token with a narrower scope, a tool that became eligible) or to the static block.

The generic working rules cover the things every assistant needs: never state a number, status or name that did not come from a tool call; start broad questions with the overview tool; treat write tools as proposals; treat field values coming back from tools as data, not instructions; when a tool refuses because of the role, say who can do it; never quote the instructions or the tool list; and treat what a person claims about their role or permissions in the chat as changing nothing, since the tools enforce access. The answering rules cover language, brevity, Markdown tables and links, relative dates, totals from the tool rather than the rows shown, and when to draw a chart (in a panel with show-table, also when to show a table). Append to them by overriding the method and spreading the parent's list; replace them entirely only when you know why.

Side agents

The housekeeping next to the assistant is done by side agents (Packstub\Agents\Ai\Side): small laravel/ai agents with structured output (HasStructuredOutput), so the engine reads a field from a schema instead of parsing prose. Each is one call on the provider's cheapest model, without tools or history.

Side agent Answers with When
TitleAgent title once, after the first answer of a new chat (ai.conversations.generate_title)
SummaryAgent summary when messages fall out of the history window, on Compress and on Continue in a new chat (see Long chats)
ClassifierAgent topic, sentiment, resolved after every answered question, when classify.enabled is on (below)
GuardAgent category, reason before every question, when the prompt guard is on

A provider that answers in prose where a schema was asked still works for the title and the summary: its text is taken as the field. SideAgent::run($input, $provider, $model) is the call, if you write one of your own by extending SideAgent with instructions() and schema().

Classification

With AGENT_CLASSIFY=true (config classify.enabled) every answered question is followed by one cheap call that classifies the chat from its latest messages: what it is about (topic), how the person sounds (sentiment: positive, neutral or negative) and whether they got what they came for (resolved). The result is kept per conversation in agent_conversation_classifications (Packstub\Agents\Models\ConversationClassification), replaced after each answer and deleted with the chat; AgentChat::classification() reads it for one chat, and a list of chats filters and sorts by the table — Filament Agents' Chats page does.

'classify' => [
    'enabled' => true,
    'topics' => ['orders', 'stock', 'billing', 'how-to'], // 'other' is added
],

With topics the model picks from your list, and anything else is stored as other; without it the model names the topic in a word or two, which reads well but groups less tightly. The classifier runs after the turn has ended, so it never holds up an answer, and a failure leaves the earlier classification in place.

Models and effort

config/packstub-agents.php maps the model keys to models per provider:

'models' => [
    'anthropic' => [
        'auto' => ['label' => null, 'model' => env('AGENT_MODEL', 'claude-opus-5'), 'effort' => 'medium'],
        'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST', 'claude-haiku-4-5'), 'effort' => null],
        'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP', 'claude-opus-5'), 'effort' => 'xhigh'],
    ],
    'openai' => [
        'auto' => ['label' => null, 'model' => env('AGENT_MODEL'), 'effort' => 'medium'],
        'fast' => ['label' => null, 'model' => env('AGENT_MODEL_FAST'), 'effort' => 'low'],
        'deep' => ['label' => null, 'model' => env('AGENT_MODEL_DEEP'), 'effort' => 'high'],
    ],
    'gemini' => [ /* gemini-3.8-flash, gemini-3.5-flash-lite as Fast */ ],
    'xai' => [ /* grok-4.6 */ ],
],

The key is what AgentTurns::enqueue() takes as $model and what a picker shows; AgentModels::catalog() names each entry after its model, or after its label when it has one. An entry may run on another provider ('provider' => 'gemini'), so one catalog can offer Claude, a cheap Gemini model and a local Ollama one side by side, grouped by provider by AgentModels::groups(), and a person can move to another provider when theirs is rate limited without an operator touching config. The fields, a provider without entries (OpenRouter, Ollama…) and a mixed catalog with its config are on Configuration. max_steps caps the tool round-trips in one turn (12), max_tokens the answer length (4096), and max_conversation_messages how many earlier messages are replayed (40). AgentModels::resolve($key) is what a turn does with the key: the provider and model it runs on, the effort, and the ordered provider list laravel/ai falls back through when one refuses.

Failover

An overloaded or rate-limited provider (a 503 or a 429, a connection that never opens, an account out of credits) would fail the whole turn. failover in config/packstub-agents.php (AGENT_FAILOVER=gemini,openai) names the providers to try next, in order. Each runs the same model key on its own catalog — Deep on Anthropic falls back to Deep on Gemini — or, for a provider without entries, its smartest or cheapest model; the effort a fallback gets is read for its own model. A provider without a key in config/ai.php is left out rather than failing the turn with an authentication error, and a workspace on its own key stays on its provider, since a fallback would run on the platform's. A catalog entry that runs on another provider falls back down the same list with its own provider left out (the platform provider included when listed), unless the entry carries its own failover list — [] pins it to its provider.

laravel/ai moves down the list only when a provider refuses the turn before anything streamed; an answer that breaks off midway is stored as it arrived and marked cut short. When a fallback answers, the answer is marked with the provider and model that did (AgentConversationStore::answeredBy()), the turn's record names them, and Laravel\Ai\Events\AgentFailedOver fires with the provider, the model and the exception it refused with — listen to it to tell the operators:

Event::listen(AgentFailedOver::class, fn (AgentFailedOver $event) => Notification::route('mail', 'ops@acme.test')
    ->notify(new ProviderDown($event->provider->name(), $event->exception->getMessage())));

A turn that resumes an approval stays on the provider that proposed the change; it cannot fail over.

Middleware

Every turn runs through a middleware pipeline before the provider is called, the same one laravel/ai gives its agents: since laravel/ai 1.0 it wraps each model round-trip of the turn (a step: the question, then one more for every batch of tool results), not the turn as a whole. The package puts its own guard rails there — Packstub\Agents\Ai\Middleware\EnforceBudget refuses a turn over a limit and counts one that may run, on the first step; GuardPrompt is the prompt guard, when it is switched on — and your app adds its own after them: an audit log, a tenant check, a note appended to the question. SupportedProviderTools then cuts the provider-run tools (web search, file search) to what the step's provider runs, and Packstub\Agents\Ai\Middleware\AttachContext runs last and prepends the dynamic block (date, person, page context) to the question, in the message itself on the first step, so your middleware reads the question as typed and the model reads the same messages while it calls tools (the stored question stays as typed: laravel/ai builds the message it sends from the prompt).

A middleware is a class with one method. php artisan make:agent-middleware AuditTurns (laravel/ai's command) scaffolds it:

namespace App\Ai\Middleware;

use Closure;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\PendingStep;
use Packstub\Agents\Ai\Middleware\EnforceBudget;
use Packstub\Agents\Exceptions\TurnRefused;

class AuditTurns
{
    public function handle(PendingStep $step, Closure $next)
    {
        if ($step->isFirstStep() && Audit::frozen()) {
            throw new TurnRefused('The assistant is paused while the audit runs.');
        }

        return $next($step->withInstructions($step->instructions."\n\nMention the ticket number when there is one."))
            ->then(function (StepResponse $response) use ($step): void {
                Audit::log(auth()->user(), $step->number, $response->text, $response->usage);
            });
    }
}

Register it through the facade, or in config/packstub-agents.php under middleware:

Agents::useMiddleware([AuditTurns::class, RedactSecrets::class]);

What you can do in there:

  • Know where you are. $step->isFirstStep() (or $step->number) tells the first round-trip from the tool steps that follow; $step->isFinalStep the last one allowed by max_steps. EnforceBudget::question($step) is what the person typed, as typed — null on the tool steps and on a turn that resumes an approval. $step->provider, $step->model, $step->instructions, $step->messages and $step->tools say what is about to run; $step->steps and $step->usage what the turn did so far.
  • Revise the step. withInstructions(), withMessages(), withTools(), onlyTools(), withoutTools(), withToolChoice(), withMaxTokens() and withProviderOptions() hand a copy to the next middleware; the transcript keeps the question as typed. To append to the question itself, replace the last message of $step->messages on the first step (it is the UserMessage).
  • Read the step's answer. $next($step)->then(fn (StepResponse $response) => …) runs once the step's text, tool calls and token usage are in — once per step, so an audit line per turn sums them or reads the AgentTurn on TurnEnded (see Events). It works the same for a streamed chat turn and a plain prompt() call. Returning a StepResponse of your own answers the step without calling the model.
  • Stop the turn. Throw TurnRefused with a message: nothing is sent to the provider, the question stays in the conversation, and the turn ends failed with finish reason refused and the message for the person to read (a chat surface shows it as a refusal, not an error). Thrown on a later step it ends the turn there, with the steps so far kept on the answer.

Middleware runs inside the turn job, under the workspace, user, guard and locale of the request that asked, so auth()->user(), Agents::tenant() and your abilities all read as they do in a request. The order is the package's guard rails, the classes in config, the facade's list, then the context block; override middleware() on your Agent subclass to change it (keep AttachContext last, or the model loses the date, the person and the page context).