particle-academy / prism-harness
Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.
Requires
- php: ^8.2
- laravel/framework: ^12.61.1|^13.12.0
- particle-academy/prism: >=0.123 <1.0
Requires (Dev)
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpstan/phpstan: ^2.0
Suggests
- particle-academy/fancy-flow-php: Drives fancy-flow's llm_call, tool_use and agent nodes through Prism, with the agent's conversation carried on a durable harness thread. Optional: fancy-flow needs PHP 8.4 and this package supports 8.2, so it is never required. See src/Flow/.
Provides
None
Conflicts
None
Replaces
None
README
Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.
Working on this package? Read
AGENTS.mdfirst — the boundary this package has to hold, the gates that must be green, and the traps that have already caught someone.@link AGENTS.md
Sessions
$session = PrismHarness::for($user)->session('support'); $session->usingMode('plan')->usingModel('claude-sonnet-4-5'); $session->lock(function (Session $session) { // whatever must not happen twice });
Resolved, never held. A Laravel request boots, serves and dies, so a session cannot be an object kept in memory the way Mastra's is. Every call rebuilds one from a store, which is what makes a fresh worker see the same mode, model and conversation as the request that set them.
The two halves
State is split into named slots, because the halves have genuinely different requirements:
| Slot | Holds | Losing it means |
|---|---|---|
ephemeral |
active mode, selected model, run bookkeeping | falls back to a default |
durable |
threads, pending tool approvals | work is gone |
Configure them independently — Redis for the first, database for the second is the intended shape:
Both default to database, so the package works on install with nothing to set up. Point the
ephemeral half at Redis when you have one — it is the better home for live session state, and
opting in beats a default that throws a connection error on a machine that never claimed to
run Redis:
'stores' => [ 'ephemeral' => 'redis', // recommended in production 'durable' => 'database', ],
Why the durable slot is guarded
A store that reports itself volatile is refused for durable state, loudly, at resolve time.
Redis is the natural home for live session state, but the redis connection in a typical
Laravel app is a cache — something is entitled to flush it. The package cannot tell from the
inside whether yours is persistent, so redis reports Volatile by default and pointing the
durable slot at it throws UnsafeStateConfiguration with both ways out named.
This is not defensive theatre. A sibling project in this workspace kept XP de-duplication in a
cache a deploy could clear; a single cache:clear between two backfills would have silently
re-awarded every contribution, with nothing in the logs. The same mistake here loses a pending
tool approval — a half-executed action a human was asked to authorise — which does not degrade
to a default.
If your Redis really is durable (AOF or RDB), say so and it is allowed:
'drivers' => [ 'redis' => [ // An assertion about your infrastructure, not a preference. 'durable' => true, ], ],
Concurrency
Two workers can hold the same session at once — a queued job finishing a run while the user
sends another message is ordinary. lock() takes an exclusive lock and throws
SessionLocked rather than running anyway on timeout, since running anyway would defeat the
only thing it is for. Locks carry an expiry, so a worker that dies mid-run does not hold the
session shut forever.
Threads
The first piece, and the one everything else needs. Prism 0.113 added a Thread contract —
a stored conversation it can read history from — and this package provides the Eloquent
implementation.
use Prism\Harness\Models\Thread; $thread = Thread::forParticipant($user, 'support'); $response = Prism::text() ->using(Provider::Anthropic, 'claude-sonnet-4-5') ->withThread($thread) // everything said so far ->withPrompt('And after that?') // the turn being taken now ->asText(); $thread->record($response->messages); // the full exchange, tool steps included
$response->messages carries every step of a tool loop, so recording a turn is one call and
a run interrupted mid-tool resumes exactly where it stopped.
Addressed by participant and scope. One user holds several unrelated conversations at
once — a support chat and a coding session are not the same thread — so the scope is part of
the address, not a label hung off it. Thread::forParticipant($user, 'coding') resolves a
different conversation from 'support', and a fresh worker asking for the same address lands
on the same thread rather than starting a new one.
The storage format is ours, not Prism's. Prism's toArray() exists to feed telemetry and
debug output and is free to change for presentational reasons; persistence cannot be, so it
does not ride on it. Two consequences worth knowing:
- Content parts are stored with their concrete class. Prism's
Media::toArray()records where a file lives but not what it is — an Image and a Document serialise identically — so without that, every attachment would come back as whatever type we guessed. - Anything that cannot be stored or rebuilt faithfully throws
UnmappableContentrather than being dropped. A thread is replayed to the model as context, so a silent omission does not surface as an error; it surfaces much later as a model that has forgotten something.
Who can write your thread rows
Prism's contract warns that stored history is replayed to the model, so it is only as
trustworthy as the store it came from. Threads make that concrete: rebuilding an attachment
resolves whatever locator was recorded, so a row carrying a local_path or url becomes a
file read or an outbound fetch at replay time. Rehydration is restricted to Prism Media
subclasses, but the locator itself is data.
None of this is reachable without write access to your database — at which point the thread
table is not your first problem. It matters because it sets where the boundary is: treat
harness_thread_messages as trusted storage, and never let request input write directly to
it.
Context window
A long conversation eventually costs more to replay than it is worth. The harness replays the whole thread by default — that has not changed and will not change on an upgrade — and gives you the structure to shorten it when you decide to.
The rule is yours. There is no correct answer to hand you: a support chat can drop old turns freely, an audit cannot drop the tool results its final count depends on, a coding agent wants neither. So the harness owns when compaction runs, what happens to what it removes, and the invariants no strategy may break. You own the rule.
// config/prism-harness.php — the shipped strategy, deterministic and no model call 'context' => ['keep_recent' => 200],
// or your own class KeepWhatMatters implements Prism\Harness\Contracts\CompactionStrategy { public function compact(array $messages): CompactionOutcome { return new CompactionOutcome(kept: /* ... */, evicted: /* ... */); } } $this->app->singleton(CompactionStrategy::class, KeepWhatMatters::class);
A summary, when a bounded window is not enough
SummarisingCompaction replaces the older half with a summary a model writes,
and rewrites that summary each time rather than appending — an append-only
précis grows without bound while looking like it compacts.
'context' => ['summarise_with' => 'claude-haiku-4-5-20251001', 'keep_recent' => 20],
Try keep_recent alone first. It costs nothing, cannot rewrite anything, and
is entirely predictable. This one bills a model call on every turn that fires —
and a second one on turns where the summary comes back over its word budget.
The budget is a request, and what to do about that is yours. summary_words
reaches the model as "in at most N words" inside the prompt, and a model does not
have to grant a request. Measured live with nothing checking: a stated 15 words
came back at 92 and at 346, and the default 60 came back at 205 — the 346 having
re-stated every exchange one by one, which is the unbounded growth rewriting is
supposed to prevent.
So there are two dials, deliberately separate: how big (summary_words) and
how that is enforced (SummaryBudget).
| budget | what it does | costs |
|---|---|---|
RetryOnce (default) |
counts, and asks once more when over — took a 60-word budget from 205 words to 61 | a second call on turns that overshoot |
AskOnly |
nothing; the behaviour before v0.6.0 | one call, and the summary may be 4x what you asked |
TruncateTo |
guarantees the bound by cutting | free, and can hand the model a fragment that reads whole |
$this->app->bind(SummaryBudget::class, fn () => new TruncateTo);
RetryOnce is allowed to miss, and does — in the same measured runs, arms
finished at 118 and 84 words against a budget of 60, meaning both calls came back
over. It cannot make things worse: a failed or longer second answer leaves the
first standing, because a summary over budget is a cost problem and no summary at
all is a lost conversation. Write your own if you would rather count tokens, or
try harder, or give up sooner — the one rule is never return an empty string,
and the strategy treats a budget that does exactly like a failed model call
rather than trusting it.
And it is the strategy most likely to lose something that matters.
"Governance Decay" puts summarisation-based
compaction above 40% safety violations, against 25–30% for truncation and 15–20%
for semantic compression, because constraints stated early are progressively lost
with nothing reporting it. Bind an EvictionSink alongside it — a summary is
a lossy view, and this only becomes safe when something else still holds the
original.
If the summary call fails, or comes back empty, the whole conversation is kept rather than the older half being dropped with nothing standing in for it.
Compaction shortens the view, never the storage. Every row stays; what changes is which of them the model sees. Change the strategy and the next turn sees a different window over the same unaltered history.
Choose a sink in the same breath
A strategy returns what it evicted, and the harness hands it to an EvictionSink. This is what
separates compaction from loss, and the default sink discards — so compaction with no sink is
context clearing without recovery, which is the configuration with the worst properties available:
the window is cheap, the agent cannot see what it did, and nothing reports an error when it
answers from the gap.
That is measured, not cautionary. On a real audit workload with results cleared and nothing able to hand them back, the agent asserted a total from evidence it no longer held, and was right only by coincidence.
One thing that workload is NOT evidence for, because we published the wrong cause once already. The same agent later abandoned the task, and its own explanation was that its counts would be "recollection" — a story about lost data. That story was wrong. Re-run with the agent's rules exempted from clearing, it cleared six times more data (77,328 tokens across six firings) and completed the whole sweep. The data was survivable; the rules were not. An agent's account of why it failed is evidence about the behaviour, not about the cause.
Bind a sink that writes into prism-memory, a table, or a log. Then bind a ContextRecall and the
agent gets a recall_context tool, so the detail is reachable rather than either resident or
gone. The tool is only offered when a ContextRecall is bound: one that always returns nothing
is worse than none, because the agent reads "nothing found" as the detail does not exist rather
than I cannot check.
What the harness guarantees whatever you write
A tool call and its result are kept or dropped together. Splitting them makes the provider reject the whole request, intermittently, several messages after the compaction that caused it — so it is enforced centrally on every strategy's output rather than documented as a rule to remember.
It does not protect your safety constraints, and you should know why. Compaction erases them: "Governance Decay" measures summarisation-based compaction producing safety violations above 40%, token truncation 25–30%, semantic compression 15–20%, because constraints stated early are progressively lost with no failure signal. In this harness the system prompt is applied per run and is not part of the thread, so it cannot be compacted away — which removes the largest instance of that.
Whatever your agent must not forget cannot live in a clearable turn, and the footgun is sharper than it sounds: if your agent loads its operating rules through a TOOL, those rules arrive as a tool result and compaction eats them like any other. That is not hypothetical — it is what made one consumer's agent abandon a task it completed fine once the rules were exempted. "Behind a tool" is only safe if that tool's results are excluded from clearing. Put the rules in the system prompt, or exempt the tool explicitly.
Tool permissions
Two abilities, because they answer different questions:
// May this tool be OFFERED to this run at all? Gate::define('harness.tool', fn ($user, Session $s, Tool $t) => $user->can('use', $t->name())); // May THIS call proceed, with THESE arguments? Gate::define('harness.tool.call', fn ($user, Session $s, Tool $t, array $args) => str_starts_with($args['path'] ?? '', '/tmp/'));
Offer-time filtering alone cannot bound how a tool is used, only whether it is present: when
the toolset is assembled the arguments do not exist yet, so harness.tool can express "may
use delete_file" and never "only under /tmp". Once offered, a tool may otherwise be
called any number of times with anything the model chooses.
Both are off by default (harness.agent.authorize_tools). But a harness.tool ability
defined while the flag is off is refused at resolve time rather than ignored — a policy
that is never consulted still reads as a control to the next person who finds it, and nothing
at runtime would have said otherwise.
Streaming
foreach ($session->stream('Refactor the billing job') as $event) { // Prism's stream events, untouched — render them as you already do } // the turn is recorded and the run closed out when the stream ends
A streamed turn and a sent turn record the same transcript, because the same code writes
both. Prism's StreamCollector yields every event through untouched and hands back the
message objects the non-streaming path builds, so there is no choice to make between
incremental delivery and a faithful record.
That was not free, and it is worth saying why it mattered. An earlier draft accumulated the deltas here and rebuilt the messages, which meant a streamed transcript could differ from a sent one in ways nothing would report: a thread is replayed to a model as context, so a message assembled slightly wrong never surfaces as an error — only, much later, as a model that remembers the conversation differently than it happened.
The lock and the run are held for the whole iteration, which is the awkward part of streaming
through a durable session. A consumer that walks away — a disconnected browser, an exception
upstream — would otherwise leave the run open until the lock TTL expired. PHP runs a
generator's finally when it is destroyed, so the run is closed on that path too and the
partial turn is recorded rather than discarded: a conversation missing the half the user
already watched stream past is the worse outcome.
Structured turns
When the answer is a document rather than prose, hand the turn a schema:
use Prism\Prism\Schema\{ArraySchema, ObjectSchema, StringSchema}; $schema = new ObjectSchema('plan', 'A proposed plan', [ new StringSchema('title', 'What the plan is called'), new ArraySchema('steps', 'What to do', new StringSchema('step', 'One step')), ], requiredFields: ['title', 'steps']); $response = $session->sendStructured('Plan the release', $schema); $response->structured(); // ['title' => 'Ship it', 'steps' => ['write', 'test']] $response->text(); // the document as the model wrote it
It is the same run as send() — the mode's system prompt, its tools, the step
budget, the lock, the events — asking the provider for structured output.
The thread keeps the text, with the parsed object beside it. The assistant
message is the raw document, and structured rides along in the message's
metadata. A later turn replays the conversation as messages and reads the text,
so a thread that contains a structured answer reads like any other — the same
argument stream() makes about a transcript that differs by request shape.
A document that misses the schema is refused, not repaired.
use Prism\Harness\Exceptions\StructuredSchemaViolation; try { $plan = $session->sendStructured($brief, $schema)->structured(); } catch (StructuredSchemaViolation $violation) { $violation->code(); // structured_schema_violation, or structured_unreadable $violation->problems(); // every way it missed, not the first $violation->document(); // what the model actually said }
Nothing is coerced, nothing is trimmed to the fields that fit, and the result is
never an empty document. An empty plan settles a batch as done, which reads
exactly like a considered answer of "nothing to propose" — the failure this
refusal exists to prevent. The exchange is still recorded, and the run is marked
failed: a thread that omits the answer it did not like cannot explain the retry
sitting next to it.
The check reads the schema's own JSON Schema, so a RawSchema is held to the
same terms. It checks declared types, required keys, enum members, array items,
and — where a schema closes itself — keys nobody declared. It does not read
$ref, allOf, oneOf or the numeric and string facets; what it cannot read,
it passes, rather than reporting a constraint it did not actually check.
Attachments
A turn can carry media alongside its prompt, on send() and stream() alike:
use Prism\Prism\ValueObjects\Media\Image; $session->send('What is wrong with this layout?', null, [ Image::fromBase64($request->string('screenshot'), 'image/png'), ]);
Attachments are stored in the thread with the turn and replayed on later turns. Each one
must be an Image, Document, Audio or Video that carries something to send: its bytes
(fromBase64(), fromRawContent(), Document::fromText()), a provider file id, or document
chunks.
Refused, with an UnacceptableAttachment that names the problem:
| Code | When |
|---|---|
attachment_by_reference |
built from a URL, or from a local or storage path |
attachment_not_media |
anything other than those four media types |
attachment_empty |
no bytes, no file id and no chunks |
attachment_without_prompt |
attachments with an empty prompt |
A URL or a path is refused even when you trust it. A URL taken from request input is
somebody else's choice of address, and a path's contents would go to a third-party model.
fromLocalPath() and fromStoragePath() read the file when they're called, so refusing here
cannot undo that read. What the refusal stops is those contents being sent onwards. For a
file your application stored itself, read it and attach the bytes.
The refusal happens before a run starts, so a refused attachment leaves no run, no events and nothing in the thread.
Whether a provider accepts a given attachment is still the provider's rule. OpenAI, for example, does not take document chunks.
Provider options per mode
A mode can pass options to the provider on every run, through Prism's withProviderOptions():
'modes' => [ 'overseer' => [ 'system_prompt' => '...', 'provider_options' => [ 'thinking' => ['type' => 'adaptive'], 'effort' => 'medium', ], ], ],
That is Anthropic's adaptive thinking, which current Claude models require. They refuse
['thinking' => ['enabled' => true, 'budgetTokens' => 4000]] with a 400; that shape is for
older models only.
The keys mean whatever the provider says they mean. The harness passes them through unchanged. A value that is not a map of option names is refused when the mode is resolved, rather than running without the option you believe is on.
Extended thinking survives a stored thread: the signature Anthropic needs on a later tool-use turn is recorded with the assistant message and replayed with it.
Voice
Press-to-talk: one utterance in, one answer out, against an ordinary session.
$reply = (new VoiceExchange)->exchange($session, Audio::fromBase64($base64, 'audio/webm')); $reply->heard; // the transcript — what the model was actually asked $reply->text; // the answer $reply->audio; // the answer spoken, or null when the turn produced no prose $reply->empty; // true when nothing was heard, and no turn was spent
The thread stores the TRANSCRIPT, not the audio. A transcript is what replays to a model, what a human reads back, and what compaction operates on; minutes of PCM in the message table would be unreplayable by anything but the original provider. Keep the recording yourself if you need it — the harness cannot decide for you whether it is evidence or a liability.
An empty transcript is not a turn. Silence, a mis-fired button and a dead
microphone all produce "", and sending that would bill a turn answering
nothing and leave an empty user message in the thread for ever. You get
empty: true instead, so you can say "I didn't catch that" rather than leaving
the user to wonder why the agent replied strangely.
Audio the harness would have to fetch is refused
Audio can be built from inline bytes, from a path on disk, or from a URL, and
those are one method name apart:
$voice->transcribe(Audio::fromBase64($request->string('audio'), 'audio/webm')); // fine $voice->transcribe(Audio::fromLocalPath($request->string('path'))); // file read $voice->transcribe(Audio::fromUrl($request->string('url'))); // SSRF
A browser microphone produces the first. So the other two are refused, with
UnsafeAudioSource (code unsafe_audio_source).
What that buys is not the same in both cases, and the difference is worth knowing before you rely on it:
- A URL is stopped outright.
fromUrl()is lazy — nothing is fetched until the request is built — so the refusal means the request is never made. - A path is stopped one step late.
fromLocalPath()andfromStoragePath()read the file inside the constructor, in your code, before the harness is called. That read cannot be prevented from here. What the refusal stops is what turns a read into a breach: the bytes being uploaded to a transcription provider, and the file coming back to the caller as text.
Transcribing a recording your own application wrote is legitimate, so it stays available behind a flag that says so:
new VoiceExchange(allowReferencedAudio: true);
That flag is an assertion about where the audio came from. It is off by default because only the caller can make it.
One thing this package cannot fix for you
When a provider call fails, the exception's trace holds VoiceExchange's own
frames, whose argument is the Audio — by then holding the decoded bytes. With
zend.exception_ignore_args=0, PHP records frame arguments, and an error
reporter that walks them (Flare and Sentry both do, by reflection) can put a
voice recording in an application log.
It cannot be closed here: a method taking an Audio has the Audio in its
arguments, and rethrowing something tidier only builds the replacement inside
the same frame. That was measured, not assumed — Prism's own frames are clean,
and a test pins both halves so a future version that starts carrying the
payload deeper fails the suite. Two things do fix it, and both are yours:
zend.exception_ignore_args=1strips frame arguments entirely. This is whatphp.ini-productionships — but a PHP with no ini file at all has it OFF, so "we never changed it" is not the safe answer.- Scrub
Prism\Prism\ValueObjects\Media\Audioin your error reporter, which also reaches the protectedrawContent, not just the publicbase64.
SummarisingCompaction has the same shape with a conversation transcript in
scope rather than a recording, and the same two remedies apply.
Not a live duplex stream, and deliberately not pretending to be one. Continuous bidirectional audio with barge-in is built on a provider's realtime socket and is a different product; a caller would otherwise discover the difference from latency rather than from the type.
Approvals
A tool that must stop and wait for a human is declared per mode, because the same tool is
not equally consequential everywhere — execute_op against a scratch project is routine and
against production is not, and the tool cannot tell which it is in:
'benchmark' => [ 'tools' => ['workspace_read', 'workspace_write', 'workspace_delete'], 'requires_approval' => ['workspace_delete'], // '*' gates everything ],
$response = $session->send('Clean up the failed run'); if ($response->awaitingApproval()) { $session->decide(array_map( fn (ToolApprovalRequest $pending) => new ToolApprovalResponse($pending->approvalId, approved: true), $response->pendingApprovals(), )); }
A single pending call can be answered with $session->approve($pending) or
$session->deny($pending, 'not on production'). Answer several with decide(): each
approve() or deny() continues the run, and Prism denies by default any call still without an
answer, so answering them one at a time refuses the rest.
The decision is a row, not a promise. It is recorded in the thread, so the approval a person grants this morning is readable by whichever worker resumes tonight — a different process, possibly after a deploy. That is the whole reason the durable slot is guarded.
Prism denies by default when it finds no response for a pending request, so a lost or
unanswered approval fails closed rather than executing. And awaitingApproval() is not a
failure: a caller that treats it as one will retry, and retrying discards the half-executed
action somebody was asked to authorise.
Events
RunStarted, RunFinished and RunFailed are ordinary Laravel events — broadcast them over
Reverb, queue them, or ignore them.
They are deliberately not telemetry. Telemetry is observability: sampled, droppable, read
by whoever is debugging. These are interface — an application builds UI on them, so they carry
a stability guarantee telemetry never will. RunFinished states awaitingApproval outright
rather than leaving a listener to infer it from a finish reason, and RunFailed carries only
the exception class, because a provider message can contain a request URL with a key in it
and an event may end up on a screen.
Every event carries run_id, parent_run_id and root_run_id — the same identifiers on the
stored rows — so an existing live stream can be joined to this record without adopting it.
Checking your configuration
php artisan harness:doctor
Resolves every mode, not the default one. Each check here mirrors a refusal that already
happens at runtime, and the refusals are correct but late: a mode nobody has entered yet keeps
its broken subagent reference until someone switches to it, and the first person to find out
is a user mid-conversation. It also reports what a mode can actually reach — a ['*'] mode is
printed as all registered tools, because "1 tool" is a true number and a false report.
Subagents
A nested run, reached through a tool, with authority it was given rather than authority it inherited. Declared per mode — a mode that names no subagents cannot spawn one:
'modes' => [ 'designer' => [ 'system_prompt' => 'You author and revise Compass Ops.', 'tools' => ['read_op', 'write_op'], 'max_steps' => 12, 'subagents' => [ 'run_op' => [ 'description' => 'Test-run an Op and report what happened.', 'mode' => 'op_runner', // the child's authority, not the parent's 'max_steps' => 4, 'max_cost_usd' => 0.25, ], ], ], 'op_runner' => [ 'system_prompt' => 'You run one Op and report the outcome.', 'tools' => ['execute_op'], // deliberately NOT the designer's tools 'max_steps' => 4, ], ],
The parent then calls run_op like any other tool. Four things are true of that call, and
each is there because the obvious implementation gets it wrong:
The child gets its own session and thread. A run holds its session lock for its whole
duration, and neither store's lock is reentrant — a child resolving the parent's address
would be refused instantly, since lock_wait defaults to 0. So the child resolves
{parent scope}::sub::{name} instead. The lock is not made reentrant on purpose: a
reentrant lock would let a child mutate parent state mid-run, which is the one thing the
lock exists to prevent. The child's thread is linked back by parent_thread_id, and every
message carries the run_id that wrote it.
Budgets nest; they do not reset. This was the open question, and it has one defensible answer. A parent bounded at 8 steps that may spawn children each entitled to a fresh 8 has no bound at all — it has a bound per node in a tree whose width it also controls. A child receives the smaller of what it declares and what the tree has left, and its spend lands in the parent's account.
A child's output is data, never instructions. An ordinary tool returns a value its
author chose; a subagent returns free text a model wrote, possibly after reading untrusted
input, and it arrives where the parent has been reading its own instructions. So it comes
back as a JSON envelope: the model-authored text confined to content, attributed to the
child run, behind an explicit note that it is material and not a directive. That is not a
guarantee — it removes the free win of splicing model output into an instruction stream
unmarked.
Every ending is its own outcome. completed / exhausted / cancelled / denied /
failed / awaiting_approval, with retryable stated. A parent that could only see
"worked or didn't" would retry what was refused on purpose and abandon what merely broke.
Cancellation is cooperative: PHP cannot interrupt a tool already executing, so the in-flight call finishes and the next step is refused. Pretending otherwise would discard a half-executed action — the exact loss the durable slot exists to prevent.
A cost cap you cannot enforce is refused
Usage::$cost is nullable, because not every provider reports one. Folding that into
+= 0.0 would leave a cost budget that reads as enforced and can never trip. Unmetered runs
are counted separately, and a tree with a max_cost_usd that has taken any of them stops
rather than spending on under a cap nobody can measure.
Task lists
An agent given a goal has to keep working across many requests until the goal is met. It needs a list of what remains, and that list has to survive the request, the worker, a crash and a deploy.
$tasks = $session->tasks(); $tasks->add('Draft the summary', 't-1'); $tasks->add('Check the figures', 't-2'); while ($tasks->pending() > 0) { $task = $tasks->claim($workerId); // one atomic call if ($task === null) { break; // someone else holds what is left } $result = $session->send($task->instruction()); $tasks->release($task, $workerId, TaskOutcome::Done); // the APPLICATION decides }
release() takes the worker, not just the task. Without it, any caller
holding a source can close any task in the list — including one another worker
is halfway through, which happens with no adversary involved: a worker whose
lease lapsed mid-task finishes, releases, and overwrites the claim of whoever
legitimately reclaimed it. The check lives on the source rather than in any one
caller, so a queued job and an HTTP route get the same guarantee the completion
tool does.
No task model, no schema, no migration. What a task is differs for every consumer and
is not this package's to decide. Two contracts — AgentTask and AgentTaskSource — and two
adapters: the store-backed list above, and Concerns\IsAgentTask, which makes a consumer's
own Eloquent model an AgentTask with conventional column names and a one-method override
per column.
Four states, and no others: todo → claimed → done / failed, plus claimed →
todo when a lease expires. Each edge is a pinned decision:
claim() is one call |
Read-then-mark as two calls is the race the design exists to prevent — both workers see the row free, both write their name, both succeed. |
| A claim carries an owner and an expiry | Five minutes by default. This is what makes a dead worker recoverable. |
claimed is written before the work begins |
So "started and died" is distinguishable from "never started". |
An expired claim returns to todo, never failed |
A worker dying is not the task failing; conflating them burns a retry that never ran. |
done and failed are terminal |
Re-releasing one is an error, not a silent no-op. |
| Order is insertion order | Nothing errors when ordering changes — the agent just does the work in a different sequence. |
The agent cannot mark its own task complete
If the model can set its own task to done, "run until the goal is met" quietly becomes
"run until it decides it is met" — and a run that has stalled ends by declaring victory.
So release() is called by the application, from evidence. A consumer that wants the
agent to close its own tasks registers Tools\TaskCompletionTool, which refuses unless the
tool authorizer is enabled and a harness.tool.call policy allows that specific call.
An offer-time policy alone is not enough on purpose: a host that trusts an agent with tools
in general has not been asked about self-completion, and silence must not read as consent
for the authority that decides whether a run is finished. The tool is bound to one worker
and refuses any task that worker is not holding.
Extending a lease, without inventing a second limit
A worker may push its own lease out while it still holds it, bounded by the run's remaining
RunBudget — cost, steps, wall-clock and cancellation, read through
RunLedger::exhaustion(). Unbounded self-extension is how a wedged worker holds a task
forever, and a fresh timeout here would be a second spelling of a limit the package already
has. Extension therefore stops exactly when the run does.
An unreadable lock expiry means wait, not take
Everything above is exclusive because the store's lock is, so the rule the lock
follows is worth stating: a lock is reclaimed only when its expiry can be shown
to have passed. An expiry that is absent, empty, or not a complete date-time is
not evidence of anything, and the row is left alone — the caller waits and gets a
SessionLocked, which is loud and recoverable.
The failure this prevents is not a parse error. Every prefix of a timestamp is an earlier timestamp, so a value truncated by a torn write does not fail to compare — it compares as long ago, and the sweep deletes a lock somebody is actively holding. Both ports of this package shipped that bug in their file stores; here the expiry is a column written by one statement, and the completeness check is what closes the remaining gap.
Ports keeping a lock in a file must write a terminator and treat a value without one as unreadable. Without it there is nothing to distinguish a complete expiry from the first half of one.
A lease is refused, never quietly adjusted
lease_seconds is refused — with the code task_lease_invalid — when it is
zero or negative, and when it is fractional. Both are one rule: a
configuration that silently becomes a different configuration is one nobody
gets to notice. 0 becoming 1 and 90.4 becoming 90 are the same shape at
two scales, and truncation landing in the safe direction (a shorter lease, so
more reclaims rather than lost work) is just the clamping argument restated.
A fractional lease could not have been honoured as written in any case:
claimed_until is an integer Unix timestamp, pinned across all three languages.
Note where the check lives. (int) '90.4' is 90, so a cast in the config file
would have truncated the value before anything could object — the guard would
have been defeated by the file that declares the setting. The raw value is
passed through and refused where it is read.
lock_wait is deliberately not held to this rule: it is a local wait bound,
never reaches a stored record, and is not part of the contract the three
languages share.
The list is durable state
A task source backed by a volatile store refuses to start, the same way the durable session slot does. A half-finished task list that vanishes on a deploy is indistinguishable from a finished one: the next run resolves the same session, finds nothing to do, and reports success having dropped the rest of its work.
What it is for
Applications where the agent is the product and the session is long-lived — an interactive coding assistant, a support console, an operations agent.
That is a different thing from an AI feature inside an app, which is what Prism and
laravel/ai already serve well: prompt, respond, maybe stream.
A harness is what you need when the conversation outlives the request, the agent switches
between ways of working, and a tool call has to stop and wait for a human.
Nothing in PHP serves that today.
The constraint that shapes everything
The prior art is Mastra's Harness (their class is now
AgentController). It cannot be ported.
Mastra keeps a Session in memory because Node holds one process across many requests. Their docs are explicit that session state, permission grants and pending approvals "don't automatically survive process recreation".
A Laravel request boots, serves and dies. So the architecture inverts:
| Mastra | a live object with optional persistence |
| Prism Harness | durable state with a reconstructed runtime |
Two properties follow, and neither is negotiable:
- Nothing is held across requests. A fresh worker resolves the same session and sees the same mode, model and pending approvals.
- A pending approval outlives everything — the request that created it, the worker that ran it, and a deploy in between. Mastra can treat an approval as an in-memory promise. Here it is a row.
Intended shape
$session = PrismHarness::for($user)->session(); // rehydrated, not constructed $session->mode('plan'); // persisted on the thread $response = $session->send('Refactor the billing job'); if ($response->awaitingApproval()) { $session->approve($response->pendingApprovals()[0]); }
Concepts, and what each maps to
Every Mastra concept has a native Laravel counterpart. Where the mapping is exact, the plan is to use the Laravel thing rather than reimplement it.
| Concept | Status | Laravel counterpart |
|---|---|---|
| Controller | shipped | PrismHarness singleton + ModeRegistry, config-driven. harness:doctor validates every mode up front |
| Session | shipped | Resolved per request from a store, keyed on participant + scope |
| Thread | shipped | Eloquent models here; contract defined in Prism (0.113) |
| Modes | partial | Modes/AgentMode.php + ModeRegistry, resolved in AgentRuntime::send(). Config-driven, not one class per mode |
| Skills | partial | Skills/SkillRegistry.php — augments the system prompt from a mode's declared skills |
| Workspace | elsewhere | A scoped Filesystem disk — built as prism-workspace |
| Permissions | shipped | harness.tool gates the offered toolset; harness.tool.call gates each invocation with its arguments. Off by default, and a policy defined while off is refused |
| Subagents | shipped | A Prism Tool wrapping a nested run. Declared per mode, own session + thread, budget drawn from the tree |
| Event bus | shipped | RunStarted / RunFinished / RunFailed as Laravel events, each carrying run lineage. Broadcast over Reverb if you want it; separate from Prism telemetry |
| Task lists | shipped | Contracts\AgentTask + Contracts\AgentTaskSource, four states, atomic claim-and-lease. Store-backed by default; Concerns\IsAgentTask adapts a consumer's own model. No task model, schema or migration ships |
Read partial as "some of this exists, and the cell says which part" — it is the status that misleads when compressed to a binary.
Every row states its status, because the previous version of this table did not and it misled someone. Bold was doing two jobs: marking Session and Thread as built, and emphasising Gates and Policies as a design choice. Identical weight, identical position, different meaning — so the planned row read exactly like the shipped ones, and a reader concluded this package gates tools on Laravel Gates.
That reader then told two other agents, one of which built on it. A status line four lines above a table does not travel with the row someone quotes.
And then it happened again, inverted. The fix above added per-row status but
also asserted, right here, that there was no Gate reference anywhere in src/. ToolAuthorizer later shipped and gates on exactly that — so the
sentence written to correct the misreading became a false claim in the opposite
direction, in the passage warning about it. It survived because our fact-checker
verifies that things named in prose exist; nobody was checking claims that
something does not. A negative claim is the more dangerous kind: it is what
a reader uses to decide something still needs building.
So this section no longer states what the code lacks. Absence is asserted in one place — the Status column — where a checker can reach it.
Decisions already taken
| Question | Decision | Why it matters |
|---|---|---|
| Where threads live | Contract in Prism, Eloquent implementation here — shipped | Prism keeps no storage opinion; anything can satisfy the interface |
| Event bus | A separate harness stream | Telemetry is observability, harness events are interface — different audiences and stability guarantees |
| State store | Redis-first behind a configurable driver — shipped | Redis and database behind one contract, with the durable slot guarded |
| Package | particle-academy/prism-harness |
Its own repo under the Particle Academy brand |
On Redis
Redis is the natural fit for live session state, but in most deployments it is a cache, and a cache is disposable by definition. The driver contract must therefore distinguish:
- Ephemeral — active mode, current model, run bookkeeping. Losing it degrades to a default.
- Durable — threads and pending approvals. Losing these means a half-executed agent action disappears.
A configuration pointing durable state at a volatile store should fail loudly rather than accept
it. This is not hypothetical caution: a sibling project in this workspace lost de-duplication
state to exactly that pattern, where a single cache:clear between two runs would have silently
double-awarded everything.
Still open
-
Whether mode is owned by the session or the thread.Decided: the session — and the case that worried us cannot arise here. Mastra's problem is that a Session and a Thread are separate objects, so one participant can hold several sessions over one thread and the mode has to belong to exactly one of them. In this package both are addressed by participant + scope:Session::key()andThread::forParticipant()take the same two values, so the mapping is 1:1 and there is nothing to come apart. Mode lives in the ephemeral half, where losing it falls back to a default rather than losing work. -
Whether subagent step budgets nest or reset.Decided: they nest. A resetting budget is not a budget — see Subagents above. -
What happens when the store is unreachable mid-claim. The claim either happened or it did not, and the worker cannot tell which. Retrying risks a double claim; not retrying risks a lost task. Today a failed claim propagates the store's own exception and nothing retries — which is the honest behaviour while the question is open, not an answer to it. A task lease bounds the damage of a claim that landed unseen: it expires and the task returns to
todo. This needs deciding before a task list is trusted with anything expensive. -
Whether a task carries a payload beyond
instruction. Consumers will want structured input. Adding it invites the task to become a job, and this is not a queue. A consumer that needs one today adapts its own model, where the payload is already a column.
Items are added back here when a decision is genuinely undecided, not to record work that is merely unfinished — that lives in the issue tracker.
Adopting this as your transcript layer
Moving an existing Chat/ChatMessage table into threads is supported, and two things are
worth knowing before you write the migration, because neither is visible from the API.
The thread table is trusted storage. Rebuilding an attachment resolves whatever locator
was recorded, so a row carrying a local_path or url becomes a file read or an outbound
fetch at replay time. Your chat rows are, by construction, populated from request input —
so a bulk migration moves user-supplied content across that boundary, and any historical
message carrying a media locator becomes a fetch the first time that thread is replayed to a
model. Sanitise locators on the way in rather than discovering this at replay.
The harness records at turn end, not mid-stream. Thread::record() takes
$response->messages once a turn completes, and messages() is a lazy read. This is the
durable record, not the live wire — keep your own streaming for the live view. Both can be
joined afterwards: every message carries its run_id, and a subagent's rows carry
parent_thread_id and root_run_id, so a nested run's activity can be correlated into an
existing stream without routing it through this package.
Background
Full analysis — including a gap comparison against laravel/ai — lives in the envelope at
.ai/discovery/laravel-ai-sdk-and-prism-harness.md.