Autonomous agents
An agent is a standing instruction to run the loop: a persona, the tools it may reach for, and something that sets it off. Nobody is in the chair.
use EduLazaro\Laragents\Models\Agent; Agent::create([ 'tenant_type' => $organization->getMorphClass(), 'tenant_id' => $organization->getKey(), 'name' => 'Document triage', 'specialty' => 'Classifies incoming documents and flags what is missing.', 'system_prompt' => 'When a document arrives, read it, classify it, and ...', 'status' => 'active', 'event_triggers' => ['document.uploaded'], 'allowed_tools' => ['read_document', 'classify_document', 'flag_missing'], 'config' => ['model' => 'gpt-4.1-mini', 'max_tasks_per_day' => 200], ]);
config is a bag for whatever your application wants to hang off an agent without the package growing a column for it. model is read from it and is required: an agent with none configured throws rather than quietly picking something and billing for it.
Agents are soft deleted. Tasks and logs point at them, and hard deleting one would orphan the record of everything it ever did, which is usually the part you wanted to keep.
Two ways to fire
use EduLazaro\Laragents\AgentTrigger; app(AgentTrigger::class)->fire('document.uploaded', $organization, $document); app(AgentTrigger::class)->due();
fire() is something happening. Every active agent of that tenant listening to that event gets a task. due() is the clock: every agent whose cron expression has come round gets one.
The event name is your string and the package compares it without understanding it.
Without this class the agents table is furniture: rows with a schedule_cron and an event_triggers list that nothing ever reads. That failure has no symptom, which is what makes it expensive. Somebody creates a triage agent, the form says it fires on uploads, and it never fires. No error, no log, no complaint, until somebody asks why the documents were never triaged.
Wiring your own events
// config/laragents.php 'events' => [ \App\Events\DocumentUploaded::class => \App\Agents\ResolveDocumentUpload::class, ],
class ResolveDocumentUpload { public function __invoke(DocumentUploaded $event): ?array { return [ 'event' => 'document.uploaded', 'tenant' => $event->document->organization, 'subject' => $event->document, 'context' => ['mime' => $event->document->mime], ]; } }
The package subscribes to your event classes and the resolver translates each one into the four things the trigger needs. Returning null is a normal answer: not every upload belongs to a tenant, and a resolver that cannot place one says so instead of guessing. A payload with no event or no tenant is ignored the same way.
A resolver class and not a closure because config gets cached, and a cached closure is a fatal error at boot. A class and not a convention because only your application knows how to get an organisation out of a file.
The schedule
'schedule_cron' => '0 8 * * 1', // Mondays at eight
Whether it is due is measured from the last run, not against the current minute, and that is the difference between a schedule and a coincidence. isDue(now()) is only true during the exact minute the expression names, so a tick lost to a restart, an overlapping run or a busy queue takes that execution with it, silently, for ever. Asking instead whether the next run after the last one has already passed means a missed minute is picked up on the following tick.
The baseline for an agent that has never run is when it was created, so switching one on does not make it fire on the spot.
A typo in one agent's expression is logged and skipped, not thrown: one bad row must not stop the tick for everybody else.
Debounce
'agents' => ['debounce_seconds' => 30],
Observers fire on every save, and a save that touches three columns in three statements is three events. Without this an agent answers the same message three times and is billed for all three.
The daily ceiling
'config' => ['max_tasks_per_day' => 50],
Also not a nicety. An agent wired to an event that fires in a loop will keep spawning tasks, and every one of them costs money. This is what stops a misconfiguration from becoming an invoice.
What a run leaves behind
$task = AgentTask::find($id); $task->status; // pending, running, completed, failed $task->trigger_type; // 'event' or 'schedule' $task->trigger_event; // your event name, when it was one $task->output; // ['content' => ..., 'finish_reason' => ...] $task->error_message; $task->logs;
Every run is wrapped so that a failure is always recorded on the task. An agent that silently stops is the hardest kind of bug to notice: no output looks exactly like nothing to do, and nobody is watching an autonomous run.
Running out of plan credits is told apart from any other failure on purpose. It is not a bug to chase, it is a plan that ran out, and the fix is commercial.
The token columns on a task are a read mirror, for showing a run's cost next to it. They are not the accounting: that belongs to whatever implements UsageRecorder, which is also what the plan is checked against.
It runs the same loop
An agent run creates a ChatSession like any conversation, so it is stored and readable exactly the same way. Its actor is null, because nobody is at the other end, and that is also what keeps personal memories out of an autonomous run: with no person to scope to, nothing personal applies.
Its system prompt is assembled the same way too: rules, then the agent's own prompt, then memories.