packstub/agents
packstub/agents is a Laravel package that provides a clean, extensible foundation for defining and running AI agents, with support for tools, prompts, and workflows that integrate smoothly into your application.
This release changes how the agent interprets free-text replies, so start by understanding the decision pipeline before you wire anything up.
config/agents.php for the decision classifier block and the AGENT_DECISION_CLASSIFIER_DRIVER / AGENT_DECISION_CLASSIFIER_MIN_CONFIDENCE env vars. If you were on 1.9, the default driver (agent) keeps you on the DecisionAgent side agent with identical behaviour — no action required to upgrade.AGENT_DECISION_CLASSIFIER_MIN_CONFIDENCE defaults to 0.8. Any decision below that becomes a question. Drivers that return no confidence (agent) are exempt, so raising the floor only affects jev and custom classifiers.Choosing a driver
| Driver | When to use | Confidence |
|---|---|---|
agent (default) |
Existing apps, no external dependency, you want the side agent to reason | none returned; floor ignored |
jev |
You want ~100 ms choice questions, no reasoning text, and a real confidence signal | yes |
| custom class | You have an internal model, a hosted endpoint, or a rules layer | your contract |
Swapping in Jev
AGENT_DECISION_CLASSIFIER_DRIVER=jev
TYPESAFE_API_KEY=...
# optional
TYPESAFE_BASE_URL=https://...
TYPESAFE_DEFAULT_MODEL=jev-latest
Jev is per-proposal and single-question, so it scales with the number of open proposals in a turn rather than the size of the whole turn. Combined with the ~100 ms round trip, it's a reasonable choice for chat UIs where the side agent's latency is noticeable.
Writing your own classifier
Implement Packstub\Agents\Contracts\DecisionClassifier and either:
Return a confidence from your implementation — otherwise you're back to the agent-driver semantics where the floor is never enforced. Wrap the external call in a timeout and fall back to "undecided"; the framework already turns a failure into a question, so degrade rather than throw.
Tuning the floor
Treat AGENT_DECISION_CLASSIFIER_MIN_CONFIDENCE as a UX dial, not a correctness one:
agent; it does nothing there.Auditing what happened
AgentTurn now records provenance, which is the cheapest observability you'll get for free:
$turn->decisionDriver(); // 'agent', 'jev', or your class name
$turn->decisionConfidence(); // lowest confidence applied; null for the side agent
Store these on your own turn/audit table, or expose them in an admin view. decisionConfidence() being the minimum across all decisions in the turn is deliberate — one shaky decision should drag the whole turn's confidence down. Log it when a turn is accepted near the floor; those are the cases most likely to be reported as "the bot did something weird".
Sync vs. queued execution
The fixed behaviour here is worth internalising, because it changes where you look when things stall:
AgentRun, an artisan command, or the email channel now executes in that request, not on the queue.AgentRun still goes to the worker. Origin and origin of the completing action are tracked separately.Practical implication: your tests and local debugging for AgentRun/command/email paths should assert synchronously. Don't assume Queue::fake() covers everything.
Two proposals at once
Multiple open proposals now behave correctly: a reply typed after you've decided one is read against the still-undecided set. The decision you already made stands. If the reply decides the rest, it joins the waiting turn and runs with it.
When a question arrives instead, the outstanding proposals are declined via AgentTurns::supersededResult() with the "not decided" note, and no ProposalDecided event fires for them. If your code listens on ProposalDecided to update external systems, you need a path for superseded proposals too — otherwise those rows sit in "pending" forever.
Tenancy
agents:run --tenant=acme, agents:embed --tenant=acme and the email channel now resolve slugs correctly, including through LaravelContext::findTenant() and findTenantBySlug() when no slug attribute is configured. Before this, Postgres would error on passing acme to an integer key. If you had defensive is_numeric() guards around tenant resolution, you can retire them.
Search
AgentChat::search() is now case-insensitive on Postgres via whereLike(..., caseSensitive: false). If you'd worked around the old like behaviour with a lower() index, keep the index — ilike still benefits from it — but drop any app-level normalisation of the search term.
agent driver, write a self-hosted classifier, or scrub question text before it leaves.agent returns no confidence and is explicitly exempt. If you set the env var and see no behavioural change, that's why — check decisionDriver() first.decisionConfidence() is null for agent — not 1.0, not 0.0. Treat null as "unknown" in any dashboard; don't average it in.DecisionClassifier in a provider is safer and easier to fake in tests.AgentTurns::supersededResult(), if downstream state depends on proposal resolution.AgentRun and asserted a queued job now needs to assert the synchronous result instead.LaravelContext tenant resolvers that assumed the old fallback semantics.LaravelContext and bind that to the interface.How can I help you explore Laravel packages today?