← Laragents 17 / 18

The data model

Seven tables. Every one of them names the thing it is about with an -able and says what everything else is, so reading this once makes the rest of the package predictable.

laragents_agents          the standing instruction
laragents_agent_tasks     one run of one agent
laragents_agent_logs      what happened during it
laragents_chat_sessions   one conversation
laragents_chat_messages   its turns, tool plumbing included
laragents_memories        what is remembered between conversations
laragents_rules           what must be obeyed within them

The polymorphic columns

Column Holds In a law firm
chat_sessions.tenant whose conversation this is, and who the usage is charged to the firm
chat_sessions.actor who is talking the lawyer
chat_sessions.sessionable what the conversation is about, or null the case
memories.tenant whose memory it is the firm
memories.memorable what it is about, or null for the tenant as a whole the case
memories.memory who it applies to, or null for everyone in that scope the lawyer, a team
rules.tenant / .memorable / .memory the same three
agents.tenant whose agent it is the firm
agent_tasks.taskable what the run is about, or null the case

All of them are two columns, _type and _id, and the package never learns what they point at. The values in _type are your morph aliases: a scope you call matter is addressed as "matter" everywhere, including in the prompt the distiller sends the model.

None of them carries a foreign key, because a morph cannot. Deleting a tenant does not cascade, which is worth knowing before somebody asks what you still hold about them.

laragents_chat_sessions

ulid                  public identifier, generated on create
tenant, actor, sessionable
agent_id              set when the session is an agent's run
title, pinned, pinned_at
total_tokens          read mirror, for showing cost next to a conversation
anonymize_pii         per-conversation privacy, default true
compressed_summary    the older turns, once
compressed_until_id   how far the summary reaches
distilled_until_id    how far memory extraction has read
last_distilled_at
metadata

The two watermarks are separate on purpose, because they advance for different reasons: compression is driven by the token budget, distillation by how much has been said since last time. A conversation can be compressed and never distilled, or the reverse.

laragents_chat_messages

session_id, role, content, metadata

role is a 20-character string rather than an enum, so a provider inventing a new one does not need a migration. The tool plumbing lives in metadata: tool_calls on an assistant turn, tool_name and tool_call_id on a tool result.

Indexed on (session_id, id), which is the only way it is ever read.

laragents_memories

tenant, memorable, memory
created_by            audit only, never decides visibility
agent_id              which agent wrote it, null on delete
source                'distilled', 'explicit', or whatever you invent
category
content
embedding             json, null when embeddings are off
metadata

created_by and memory are different things and the migration says so: one is who wrote it, the other is who it belongs to. An agent writing a memory about a client is neither.

agent_id is null on delete: what was learned outlives the thing that learned it.

embedding is JSON, not a vector type, because scoring happens in PHP over a bounded pool. No extension, no vector database, and a real ceiling in the low thousands per scope.

Indexed on (tenant_type, tenant_id, created_at), because selection is "the newest N for this tenant". It leads with the tenant and not the memorable because most rows have no memorable: they are about the tenant as a whole. The narrower lookup rides the index nullableMorphs already made.

laragents_rules

The same three axes, plus:

content
priority    ascending, 0 first, ties break by id
enabled     off rather than deleted

laragents_agents

ulid, tenant, created_by
name, avatar, specialty
system_prompt
status              free string, compared against config
schedule_cron
event_triggers      json, your event names
allowed_tools       json, null = whatever the caller passes
config              json, the bag
tasks_completed, tasks_failed, total_tokens_used
last_run_at
deleted_at          soft delete

status is a free string and the package compares it against agents.active_status. Comparing it against a word of ours would quietly turn it into an enum, and an application that writes enabled or live would get no agents firing and no error to explain it.

avatar exists because an agent with no face, in a list next to people, reads as something broken.

Soft deleted, because tasks and logs point here and hard deleting would orphan the record of everything it ever did.

laragents_agent_tasks

agent_id, taskable
trigger_type        'event' or 'schedule'
trigger_event       your event name
status              pending, running, completed, failed
input, output       json
input_tokens, output_tokens, credits_consumed
started_at, completed_at, error_message, metadata

The token columns are a read mirror, for showing a run's cost beside it. They are not the accounting: that belongs to whatever implements UsageRecorder. Two places holding the same number is a smell, and the alternative is joining an application-owned table to render a list.

The one migration that touches your tables

php artisan vendor:publish --tag=laragents-optout-migration

Adds share_memories to your users table, nullable, three-state. It is a separate tag because it is the only one that touches a table the package does not own, it asks which table rather than guessing, and it skips a column that is already there.