Search by

particle-academy / prism-harness

wishborn

Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.

Package info

github.com/Particle-Academy/prism-harness

pkg:composer/particle-academy/prism-harness

Statistics

Installs: 422

Dependents: 1

Suggesters: 4

Stars: 0

Open Issues: 0

v0.11.1 2026-09-14 17:34 UTC

README

Durable agent sessions for Laravel — threads, modes, tool permissions and subagents on top of Prism.

Working on this package? Read AGENTS.md first — 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 UnmappableContent rather 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() and fromStoragePath() 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=1 strips frame arguments entirely. This is what php.ini-production ships — 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\Audio in your error reporter, which also reaches the protected rawContent, not just the public base64.

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: todoclaimeddone / failed, plus claimedtodo 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:

  1. Nothing is held across requests. A fresh worker resolves the same session and sees the same mode, model and pending approvals.
  2. 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() and Thread::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.