michal78/laravel-pandora
An agentic framework for Laravel: build agents, tools, approvals, automations and memory under explicit, auditable policy, with a Livewire control center for operating them.
Install the package and publish its config and migrations, then run php artisan migrate. The migrations matter more than usual here: runs, run state, run locks, provider health, and sessions all persist state that the security controls depend on.
Look first at:
config/*.php budgets — token, cost, step, and wall-clock limits. All four are enforced by assertWithinBudget() in a fixed order, and the first breach throws. Set every limit deliberately; a generous default on one limit can mask a bug in another.routes/web.php and src/UI/Livewire/ — authorization is intentionally enforced inside each Livewire component, not only by route middleware. The architectural test asserts every class in src/UI/Livewire/ authorizes somewhere, so any new control-center page must include its own gate.Agent::deniedTools() and AbilityIntersection — delegation is granted, minus denied. If you use delegated agents, confirm the parent’s deny list is part of the intersection, not just the parent’s missing abilities.A good first use case is a single agent with one custom tool:
$agent = Agent::make('summarizer')
->grant(['docs.read'])
->deny(['docs.delete'])
->tools([SummarizeTool::class]);
$run = $agent->run('Summarize the attached document.');
Run it through the control center once, then through the programmatic API. The control-center path exercises the Livewire authorization gates, run locking, and budget checks that a unit test may bypass.
Tool execution and wall-clock budgets. A tool’s handle() method is the only place a test or application can act between two iterations of an inline run. If you need to exercise a wall-clock limit, use a SlowTool-style fixture that advances the clock from inside handle(). In production, long-running tools are the realistic way to reach Run::hasExceededDeadline().
Delegation. Resolve child tools through AbilityIntersection::abilitiesOfAgent() rather than constructing the child’s tool list yourself. The parent’s explicit denies must flow into the child’s frozen list. Test the deny path explicitly:
$parent = Agent::make('parent')->grant(['reports.read'])->deny(['reports.export']);
$child = $parent->delegate('child');
// The child must not receive reports.export, even if it is otherwise granted.
A useful assertion is not only “the child lacks the tool” but “calling the tool returns denied rather than succeeded.” The release notes describe a case where the child executes successfully under ablation because the gatekeeper checks the child’s policy and frozen list, neither of which mentions the parent’s deny list.
Session isolation. Session::isolationKeyFor() folds seven components into one hash. Treat actor_type as load-bearing. ActorContext::system() accepts an arbitrary label as its id, so an automation labelled with a user’s primary key will share that user’s id and differ only in type. If your automation labels can collide with user IDs, ensure the type remains part of the key. When resolving sessions, remember that SessionResolver folds the conversation into the origin so two conversations with the same agent and actor do not share a boundary.
Workspace writes. Prefer WorkspaceFiles::write() over direct filesystem access. It performs quota accounting via storage->size() and containment via LocalStorage::locate(). If you drive LocalStorage directly, call locate(mustExist: false) for write targets that already exist: a contained parent says nothing about whether the leaf is a symlink. The re-check protects against a symlinked leaf; the parent check protects against a symlinked directory. Test both shapes.
Concurrency. Use the database-backed RunLock lease as the authority for run ownership. For tests, use tests/Support/RunsConcurrently.php rather than pest --parallel; the harness starts real OS processes against one database and releases them from a barrier. The barrier is essential because application boot dwarfs the contended section. Without it, processes run serially and a concurrency control can be deleted while the test stays green.
Provider health writes. Record provider outcomes through ProviderHealthMonitor, but keep health writes off the critical path of a provider call where possible. In v0.1.4, rowFor() was changed to avoid a firstOrNew() + save() race on the uniquely indexed provider_key. If you are on an older version, wrap health recording so a duplicate-key exception cannot escape into the run.
Control-center pages. Every Livewire page should authorize explicitly. Index pages and detail pages have different exposure: a detail page can reveal a prompt, a webhook secret, or a full execution trace. Add a denial test for each new page, and rely on the architectural test to catch a page that never authorizes.
RunState::TimedOut does not mean “ran out of time.” It is where every budget breach lands. If you assert only that a BudgetExceeded was thrown, the test passes even when the limit you intended to test has been deleted. Assert the message of the limit under test and set every other limit generously.RunLock serial tests are not enough. The database lease was documented as “the authority” but could be deleted with all 22 serial lock tests green, including one named “grants ownership to one worker and refuses a second.” Only the concurrent harness exposed five processes acquiring the same run.WorkspaceFiles::write() calls storage->size() for quota accounting first, and size() resolves with mustExist: true, so an escaping symlink may be refused by the byte-count lookup before the containment re-check is reached. Test LocalStorage directly to exercise the second layer.id and differs only in type. Dropping actor_type from the isolation key merged those sessions while 1,820 tests stayed green.origin ready-made and never exercises how SessionResolver folds the conversation into it. Assert the composed origin directly if conversation boundaries matter to you.Queue/ConcurrentHarnessTest before trusting any test built on it.SlowTool fixture for wall-clock tests rather than sleeping in the test process.firstOrNew() + save() path could fail a run when two workers recorded the first outcome for the same provider at the same instant.handle() contract, custom actor contexts (especially ActorContext::system() labels), and custom storage implementations that honor LocalStorage::locate(mustExist: false) semantics.How can I help you explore Laravel packages today?